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,266 @@
|
|
|
1
|
+
"""
|
|
2
|
+
本地事件存储。
|
|
3
|
+
|
|
4
|
+
使用 JSON 文件作为单用户场景下的轻量事件仓储:
|
|
5
|
+
- 事件持久化
|
|
6
|
+
- 原子写入
|
|
7
|
+
- 简单文件锁
|
|
8
|
+
- 查询 / 删除 / 同步状态更新
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import uuid
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from tempfile import NamedTemporaryFile
|
|
20
|
+
from typing import Iterator
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import fcntl
|
|
24
|
+
except ImportError: # pragma: no cover - Windows fallback
|
|
25
|
+
fcntl = None
|
|
26
|
+
|
|
27
|
+
from config.settings import LOCAL_EVENTS_FILE
|
|
28
|
+
from gcal.models import CalendarEvent
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _default_store() -> dict:
|
|
32
|
+
return {"version": 1, "events": []}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_store_path(path: str | Path | None = None) -> Path:
|
|
36
|
+
return Path(path) if path is not None else LOCAL_EVENTS_FILE
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _atomic_write_json(path: Path, data: dict) -> None:
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
tmp_name = None
|
|
42
|
+
try:
|
|
43
|
+
with NamedTemporaryFile(
|
|
44
|
+
"w",
|
|
45
|
+
dir=path.parent,
|
|
46
|
+
delete=False,
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
) as tmp:
|
|
49
|
+
json.dump(data, tmp, indent=2, ensure_ascii=False)
|
|
50
|
+
tmp.flush()
|
|
51
|
+
os.fsync(tmp.fileno())
|
|
52
|
+
tmp_name = tmp.name
|
|
53
|
+
os.replace(tmp_name, path)
|
|
54
|
+
finally:
|
|
55
|
+
if tmp_name and os.path.exists(tmp_name):
|
|
56
|
+
os.unlink(tmp_name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize_record(raw: dict) -> dict:
|
|
60
|
+
record = dict(raw)
|
|
61
|
+
record.setdefault("id", f"local_{uuid.uuid4().hex[:12]}")
|
|
62
|
+
record.setdefault("summary", "无标题")
|
|
63
|
+
record.setdefault("status", "confirmed")
|
|
64
|
+
record.setdefault("is_all_day", False)
|
|
65
|
+
record.setdefault("location", "")
|
|
66
|
+
record.setdefault("description", "")
|
|
67
|
+
record.setdefault("remind_minutes", 0)
|
|
68
|
+
record.setdefault("google_event_id", "")
|
|
69
|
+
record.setdefault("sync_state", "local_only")
|
|
70
|
+
record.setdefault("last_sync_error", "")
|
|
71
|
+
record.setdefault("created_at", record.get("start", ""))
|
|
72
|
+
record.setdefault("updated_at", record.get("created_at", ""))
|
|
73
|
+
return record
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_store(path: Path) -> dict:
|
|
77
|
+
try:
|
|
78
|
+
with open(path, encoding="utf-8") as f:
|
|
79
|
+
data = json.load(f)
|
|
80
|
+
except FileNotFoundError:
|
|
81
|
+
return _default_store()
|
|
82
|
+
if not isinstance(data, dict):
|
|
83
|
+
raise ValueError("local_events.json 格式错误:根节点必须是对象")
|
|
84
|
+
data.setdefault("version", 1)
|
|
85
|
+
events = data.setdefault("events", [])
|
|
86
|
+
if not isinstance(events, list):
|
|
87
|
+
raise ValueError("local_events.json 格式错误:events 必须是数组")
|
|
88
|
+
data["events"] = [_normalize_record(item) for item in events if isinstance(item, dict)]
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@contextmanager
|
|
93
|
+
def _locked_store(path: str | Path | None = None) -> Iterator[tuple[Path, dict]]:
|
|
94
|
+
store_path = resolve_store_path(path)
|
|
95
|
+
store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
lock_path = store_path.with_suffix(store_path.suffix + ".lock")
|
|
97
|
+
with open(lock_path, "a+", encoding="utf-8") as lock_file:
|
|
98
|
+
if fcntl is not None:
|
|
99
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
100
|
+
yield store_path, _read_store(store_path)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _ensure_aware(dt: datetime) -> datetime:
|
|
104
|
+
if dt.tzinfo is None:
|
|
105
|
+
raise ValueError("datetime 必须携带时区信息")
|
|
106
|
+
return dt
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def record_to_event(record: dict) -> CalendarEvent:
|
|
110
|
+
return CalendarEvent(
|
|
111
|
+
id=record["id"],
|
|
112
|
+
summary=record.get("summary", "无标题"),
|
|
113
|
+
start=datetime.fromisoformat(record["start"]),
|
|
114
|
+
end=datetime.fromisoformat(record["end"]),
|
|
115
|
+
status=record.get("status", "confirmed"),
|
|
116
|
+
is_all_day=bool(record.get("is_all_day", False)),
|
|
117
|
+
location=record.get("location", ""),
|
|
118
|
+
description=record.get("description", ""),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_store(path: str | Path | None = None) -> dict:
|
|
123
|
+
with _locked_store(path) as (_, data):
|
|
124
|
+
return data
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def list_records(
|
|
128
|
+
time_min: datetime | None = None,
|
|
129
|
+
time_max: datetime | None = None,
|
|
130
|
+
include_cancelled: bool = False,
|
|
131
|
+
path: str | Path | None = None,
|
|
132
|
+
) -> list[dict]:
|
|
133
|
+
with _locked_store(path) as (_, data):
|
|
134
|
+
records = []
|
|
135
|
+
for raw in data.get("events", []):
|
|
136
|
+
record = _normalize_record(raw)
|
|
137
|
+
if not include_cancelled and record.get("status") == "cancelled":
|
|
138
|
+
continue
|
|
139
|
+
event = record_to_event(record)
|
|
140
|
+
if time_min is not None and _ensure_aware(event.end) <= _ensure_aware(time_min):
|
|
141
|
+
continue
|
|
142
|
+
if time_max is not None and _ensure_aware(event.start) >= _ensure_aware(time_max):
|
|
143
|
+
continue
|
|
144
|
+
records.append(record)
|
|
145
|
+
records.sort(key=lambda item: item.get("start", ""))
|
|
146
|
+
return records
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def list_events(
|
|
150
|
+
time_min: datetime | None = None,
|
|
151
|
+
time_max: datetime | None = None,
|
|
152
|
+
include_cancelled: bool = False,
|
|
153
|
+
path: str | Path | None = None,
|
|
154
|
+
) -> list[CalendarEvent]:
|
|
155
|
+
return [
|
|
156
|
+
record_to_event(record)
|
|
157
|
+
for record in list_records(
|
|
158
|
+
time_min=time_min,
|
|
159
|
+
time_max=time_max,
|
|
160
|
+
include_cancelled=include_cancelled,
|
|
161
|
+
path=path,
|
|
162
|
+
)
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_record(event_id: str, path: str | Path | None = None) -> dict | None:
|
|
167
|
+
with _locked_store(path) as (_, data):
|
|
168
|
+
for raw in data.get("events", []):
|
|
169
|
+
record = _normalize_record(raw)
|
|
170
|
+
if record["id"] == event_id:
|
|
171
|
+
return record
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_event_status(event_id: str, path: str | Path | None = None) -> str | None:
|
|
176
|
+
record = get_record(event_id, path=path)
|
|
177
|
+
return record.get("status") if record else None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def create_event(
|
|
181
|
+
summary: str,
|
|
182
|
+
start: datetime,
|
|
183
|
+
end: datetime,
|
|
184
|
+
location: str = "",
|
|
185
|
+
description: str = "",
|
|
186
|
+
remind_minutes: int = 0,
|
|
187
|
+
path: str | Path | None = None,
|
|
188
|
+
) -> dict:
|
|
189
|
+
start = _ensure_aware(start)
|
|
190
|
+
end = _ensure_aware(end)
|
|
191
|
+
now = datetime.now(start.tzinfo)
|
|
192
|
+
record = _normalize_record({
|
|
193
|
+
"id": f"local_{uuid.uuid4().hex[:12]}",
|
|
194
|
+
"summary": summary,
|
|
195
|
+
"start": start.isoformat(),
|
|
196
|
+
"end": end.isoformat(),
|
|
197
|
+
"status": "confirmed",
|
|
198
|
+
"is_all_day": False,
|
|
199
|
+
"location": location,
|
|
200
|
+
"description": description,
|
|
201
|
+
"remind_minutes": int(remind_minutes),
|
|
202
|
+
"google_event_id": "",
|
|
203
|
+
"sync_state": "local_only",
|
|
204
|
+
"last_sync_error": "",
|
|
205
|
+
"created_at": now.isoformat(),
|
|
206
|
+
"updated_at": now.isoformat(),
|
|
207
|
+
})
|
|
208
|
+
with _locked_store(path) as (store_path, data):
|
|
209
|
+
events = data.setdefault("events", [])
|
|
210
|
+
events.append(record)
|
|
211
|
+
_atomic_write_json(store_path, data)
|
|
212
|
+
return record
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def delete_event(event_id: str, path: str | Path | None = None) -> bool:
|
|
216
|
+
with _locked_store(path) as (store_path, data):
|
|
217
|
+
changed = False
|
|
218
|
+
for record in data.get("events", []):
|
|
219
|
+
normalized = _normalize_record(record)
|
|
220
|
+
if normalized["id"] != event_id:
|
|
221
|
+
continue
|
|
222
|
+
record["status"] = "cancelled"
|
|
223
|
+
record["updated_at"] = datetime.now().isoformat()
|
|
224
|
+
if not record.get("sync_state"):
|
|
225
|
+
record["sync_state"] = "local_only"
|
|
226
|
+
changed = True
|
|
227
|
+
break
|
|
228
|
+
if changed:
|
|
229
|
+
_atomic_write_json(store_path, data)
|
|
230
|
+
return changed
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def update_sync_status(
|
|
234
|
+
event_id: str,
|
|
235
|
+
sync_state: str,
|
|
236
|
+
google_event_id: str = "",
|
|
237
|
+
last_sync_error: str = "",
|
|
238
|
+
path: str | Path | None = None,
|
|
239
|
+
) -> bool:
|
|
240
|
+
with _locked_store(path) as (store_path, data):
|
|
241
|
+
changed = False
|
|
242
|
+
for record in data.get("events", []):
|
|
243
|
+
normalized = _normalize_record(record)
|
|
244
|
+
if normalized["id"] != event_id:
|
|
245
|
+
continue
|
|
246
|
+
record["sync_state"] = sync_state
|
|
247
|
+
record["google_event_id"] = google_event_id
|
|
248
|
+
record["last_sync_error"] = last_sync_error
|
|
249
|
+
record["updated_at"] = datetime.now().isoformat()
|
|
250
|
+
changed = True
|
|
251
|
+
break
|
|
252
|
+
if changed:
|
|
253
|
+
_atomic_write_json(store_path, data)
|
|
254
|
+
return changed
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def list_syncable_records(path: str | Path | None = None) -> list[dict]:
|
|
258
|
+
records = list_records(include_cancelled=False, path=path)
|
|
259
|
+
result = []
|
|
260
|
+
for record in records:
|
|
261
|
+
if record.get("status") != "confirmed":
|
|
262
|
+
continue
|
|
263
|
+
if record.get("sync_state") == "synced" and record.get("google_event_id"):
|
|
264
|
+
continue
|
|
265
|
+
result.append(record)
|
|
266
|
+
return result
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
提醒计划生成器。
|
|
3
|
+
|
|
4
|
+
从事件列表生成提醒计划,处理全天事件、已过期提醒等边界情况。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from compat import ZoneInfo, make_aware
|
|
20
|
+
from gcal.models import CalendarEvent
|
|
21
|
+
from .event_classifier import EventClassifier
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_DEFAULT_RULES_PATH = Path(__file__).parent.parent / "config" / "reminder_rules.yaml"
|
|
26
|
+
_DEFAULT_TZ = "Asia/Shanghai"
|
|
27
|
+
|
|
28
|
+
_BUILTIN_LEAD = {"sport": 15, "meal": 15, "family": 15, "medical": 30, "appointment": 10}
|
|
29
|
+
_BUILTIN_DEFAULT_LEAD = 10
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_lead_minutes(path: Path) -> tuple[dict[str, int], int]:
|
|
33
|
+
try:
|
|
34
|
+
with open(path, encoding="utf-8") as f:
|
|
35
|
+
data = yaml.safe_load(f) or {}
|
|
36
|
+
lead = {k: int(v) for k, v in data.get("lead_minutes", {}).items()}
|
|
37
|
+
default = int(data.get("default_lead_minutes", _BUILTIN_DEFAULT_LEAD))
|
|
38
|
+
return lead or _BUILTIN_LEAD, default
|
|
39
|
+
except Exception as e:
|
|
40
|
+
logger.warning(f"加载提醒规则失败,使用内置: {e}")
|
|
41
|
+
return _BUILTIN_LEAD, _BUILTIN_DEFAULT_LEAD
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ReminderPlan:
|
|
46
|
+
event: CalendarEvent
|
|
47
|
+
event_type: str # sport / meal / family / medical / appointment
|
|
48
|
+
lead_minutes: int # 提前多少分钟
|
|
49
|
+
remind_at: datetime # 北京时间(aware)
|
|
50
|
+
remind_at_utc: datetime # UTC(aware)
|
|
51
|
+
should_schedule: bool # False = 提醒时间已过,跳过创建
|
|
52
|
+
|
|
53
|
+
def reminder_message(self) -> str:
|
|
54
|
+
t_start = self.event.start.strftime("%H:%M")
|
|
55
|
+
t_end = self.event.end.strftime("%H:%M") if self.event.end else ""
|
|
56
|
+
time_range = f"{t_start}–{t_end}" if t_end and t_end != t_start else t_start
|
|
57
|
+
|
|
58
|
+
lines = [f"⏰ **{self.event.summary}** 将在 {self.lead_minutes} 分钟后开始"]
|
|
59
|
+
lines.append(f"🕐 {time_range}(共 {self.event.duration_minutes()} 分钟)")
|
|
60
|
+
if self.event.location:
|
|
61
|
+
lines.append(f"📍 {self.event.location}")
|
|
62
|
+
if self.event.description:
|
|
63
|
+
first_line = self.event.description.split("\n")[0].strip()
|
|
64
|
+
if first_line and len(first_line) <= 60:
|
|
65
|
+
lines.append(f"📝 {first_line}")
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def plan_reminders(
|
|
70
|
+
events: list[CalendarEvent],
|
|
71
|
+
now: datetime | None = None,
|
|
72
|
+
rules_path: Path | None = None,
|
|
73
|
+
classifier: EventClassifier | None = None,
|
|
74
|
+
tz: str = _DEFAULT_TZ,
|
|
75
|
+
) -> list[ReminderPlan]:
|
|
76
|
+
"""
|
|
77
|
+
从事件列表生成提醒计划列表。
|
|
78
|
+
|
|
79
|
+
跳过:
|
|
80
|
+
- 全天事件(is_all_day=True)
|
|
81
|
+
- start 为空的事件
|
|
82
|
+
标记 should_schedule=False(不跳过,但不创建 cron 任务):
|
|
83
|
+
- 提醒时间早于 now
|
|
84
|
+
"""
|
|
85
|
+
zone = ZoneInfo(tz)
|
|
86
|
+
if now is None:
|
|
87
|
+
now = datetime.now(zone)
|
|
88
|
+
if now.tzinfo is None:
|
|
89
|
+
now = now.replace(tzinfo=zone)
|
|
90
|
+
|
|
91
|
+
lead_map, default_lead = _load_lead_minutes(rules_path or _DEFAULT_RULES_PATH)
|
|
92
|
+
clf = classifier or EventClassifier(rules_path)
|
|
93
|
+
|
|
94
|
+
plans: list[ReminderPlan] = []
|
|
95
|
+
for event in events:
|
|
96
|
+
if event.is_all_day:
|
|
97
|
+
logger.debug(f"跳过全天事件: {event.summary}")
|
|
98
|
+
continue
|
|
99
|
+
if event.start is None:
|
|
100
|
+
logger.debug(f"跳过无开始时间事件: {event.summary}")
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
event_type = clf.classify(event.summary, event.description)
|
|
104
|
+
lead = lead_map.get(event_type, default_lead)
|
|
105
|
+
remind_at = event.start - timedelta(minutes=lead)
|
|
106
|
+
|
|
107
|
+
plans.append(ReminderPlan(
|
|
108
|
+
event = event,
|
|
109
|
+
event_type = event_type,
|
|
110
|
+
lead_minutes = lead,
|
|
111
|
+
remind_at = remind_at,
|
|
112
|
+
remind_at_utc = remind_at.astimezone(timezone.utc),
|
|
113
|
+
should_schedule = remind_at >= now,
|
|
114
|
+
))
|
|
115
|
+
|
|
116
|
+
return plans
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
运行时工具。
|
|
3
|
+
|
|
4
|
+
统一处理:
|
|
5
|
+
- Python 解释器选择
|
|
6
|
+
- task_registry 占位符替换
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from config.settings import OPENCLAW_BASE, PYTHON_BIN, SKILL_BASE
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def expand_command_vars(text: str) -> str:
|
|
17
|
+
expanded = (
|
|
18
|
+
text
|
|
19
|
+
.replace("{OPENCLAW_BASE}", str(OPENCLAW_BASE))
|
|
20
|
+
.replace("{SKILL_BASE}", str(SKILL_BASE))
|
|
21
|
+
.replace("{PYTHON_BIN}", PYTHON_BIN)
|
|
22
|
+
)
|
|
23
|
+
if expanded.startswith("python3 "):
|
|
24
|
+
return expanded.replace("python3 ", f"{PYTHON_BIN} ", 1)
|
|
25
|
+
if expanded.startswith("python "):
|
|
26
|
+
return expanded.replace("python ", f"{PYTHON_BIN} ", 1)
|
|
27
|
+
return expanded
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_python_command(script: str | Path, *args: str) -> list[str]:
|
|
31
|
+
return [PYTHON_BIN, str(script), *[str(arg) for arg in args]]
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Markdown 表格解析器。
|
|
3
|
+
|
|
4
|
+
从 markitdown 或 OCR 输出的 Markdown 内容中提取结构化日程事件。
|
|
5
|
+
移植自 skills/schedule-reminder/scripts/parse_schedule.py 的列名识别逻辑,
|
|
6
|
+
输出格式与 add_event.py / import_events.py 兼容。
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
from services.table_parser import parse_events_from_markdown
|
|
10
|
+
events = parse_events_from_markdown(md_content)
|
|
11
|
+
# events: [{"summary": "...", "date": "YYYY-MM-DD", "start": "HH:MM", ...}, ...]
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import date, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
23
|
+
|
|
24
|
+
from services.datetime_utils import current_year
|
|
25
|
+
|
|
26
|
+
# ─────────────────────────── 列名别名表 ──────────────────────────
|
|
27
|
+
# 各字段的候选列名(中英文、简写、Excel 常见变体)
|
|
28
|
+
|
|
29
|
+
COLUMN_ALIASES: dict[str, set[str]] = {
|
|
30
|
+
"date": {
|
|
31
|
+
"日期", "date", "Date", "DATE", "日", "活动日期", "会议日期",
|
|
32
|
+
"事件日期", "日程日期", "计划日期", "时间",
|
|
33
|
+
},
|
|
34
|
+
"start": {
|
|
35
|
+
"开始时间", "开始", "start", "Start", "START", "开始时刻",
|
|
36
|
+
"起始时间", "from", "From", "时间开始", "起始", "开始时间段",
|
|
37
|
+
},
|
|
38
|
+
"end": {
|
|
39
|
+
"结束时间", "结束", "end", "End", "END", "结束时刻",
|
|
40
|
+
"to", "To", "时间结束", "终止",
|
|
41
|
+
},
|
|
42
|
+
"title": {
|
|
43
|
+
"事项", "标题", "title", "Title", "TITLE", "内容", "会议", "活动",
|
|
44
|
+
"事件", "subject", "Subject", "name", "Name", "任务", "描述",
|
|
45
|
+
"会议名称", "活动名称", "事项名称", "议题", "主题", "项目",
|
|
46
|
+
"meeting", "Meeting", "event", "Event", "topic", "Topic",
|
|
47
|
+
},
|
|
48
|
+
"location": {
|
|
49
|
+
"地点", "location", "Location", "LOCATION", "地址", "会议室",
|
|
50
|
+
"venue", "Venue", "place", "Place", "room", "Room", "场所", "场地",
|
|
51
|
+
},
|
|
52
|
+
"participants": {
|
|
53
|
+
"参与者", "人员", "participants", "Participants", "与会者", "成员",
|
|
54
|
+
"attendees", "Attendees", "负责人", "参会人", "参会人员",
|
|
55
|
+
},
|
|
56
|
+
"advance": {
|
|
57
|
+
"提前(分钟)", "提前提醒", "提前", "advance", "Advance",
|
|
58
|
+
"reminder", "Reminder", "提前时间", "提醒时间", "提前分钟",
|
|
59
|
+
},
|
|
60
|
+
"note": {
|
|
61
|
+
"备注", "note", "Note", "NOTE", "notes", "Notes",
|
|
62
|
+
"说明", "remark", "Remark", "comments", "附注", "补充",
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_DEFAULT_DURATION_MINUTES = 60
|
|
67
|
+
_DEFAULT_ADVANCE_MINUTES = 15
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ─────────────────────────── 列名检测 ────────────────────────────
|
|
71
|
+
|
|
72
|
+
def detect_column_mapping(
|
|
73
|
+
header_cells: list[str],
|
|
74
|
+
user_map: dict[str, str] | None = None,
|
|
75
|
+
) -> dict[str, int]:
|
|
76
|
+
"""
|
|
77
|
+
根据表头行自动推断字段→列索引映射。
|
|
78
|
+
|
|
79
|
+
优先级:用户自定义 > 精确别名匹配 > 子串匹配。
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
header_cells: Markdown 表头各列文字列表
|
|
83
|
+
user_map: 自定义映射 {"自定义列名": "字段名", ...}
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
{"title": 0, "date": 1, "start": 2, ...}
|
|
87
|
+
"""
|
|
88
|
+
mapping: dict[str, int] = {}
|
|
89
|
+
used_indices: set[int] = set()
|
|
90
|
+
|
|
91
|
+
# 用户自定义(最高优先级)
|
|
92
|
+
if user_map:
|
|
93
|
+
for i, cell in enumerate(header_cells):
|
|
94
|
+
cell_clean = cell.strip()
|
|
95
|
+
if cell_clean in user_map:
|
|
96
|
+
field = user_map[cell_clean]
|
|
97
|
+
if field not in mapping:
|
|
98
|
+
mapping[field] = i
|
|
99
|
+
used_indices.add(i)
|
|
100
|
+
|
|
101
|
+
# 精确匹配
|
|
102
|
+
for field, aliases in COLUMN_ALIASES.items():
|
|
103
|
+
if field in mapping:
|
|
104
|
+
continue
|
|
105
|
+
for i, cell in enumerate(header_cells):
|
|
106
|
+
if i in used_indices:
|
|
107
|
+
continue
|
|
108
|
+
if cell.strip() in aliases:
|
|
109
|
+
mapping[field] = i
|
|
110
|
+
used_indices.add(i)
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
# 子串匹配(兜底)
|
|
114
|
+
for field, aliases in COLUMN_ALIASES.items():
|
|
115
|
+
if field in mapping:
|
|
116
|
+
continue
|
|
117
|
+
for i, cell in enumerate(header_cells):
|
|
118
|
+
if i in used_indices:
|
|
119
|
+
continue
|
|
120
|
+
cell_lower = cell.strip().lower()
|
|
121
|
+
if any(a.lower() in cell_lower or cell_lower in a.lower()
|
|
122
|
+
for a in aliases):
|
|
123
|
+
mapping[field] = i
|
|
124
|
+
used_indices.add(i)
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
return mapping
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ─────────────────────────── 时间规范化 ──────────────────────────
|
|
131
|
+
|
|
132
|
+
def normalize_date(raw: str) -> Optional[str]:
|
|
133
|
+
"""
|
|
134
|
+
将各种日期格式规范化为 YYYY-MM-DD,失败返回 None。
|
|
135
|
+
支持:2026-04-01 / 2026/04/01 / 04-01 / 4/1(补全年份)
|
|
136
|
+
"""
|
|
137
|
+
raw = raw.strip()
|
|
138
|
+
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
|
|
139
|
+
try:
|
|
140
|
+
return datetime.strptime(raw, fmt).strftime("%Y-%m-%d")
|
|
141
|
+
except ValueError:
|
|
142
|
+
pass
|
|
143
|
+
for fmt in ("%m-%d", "%m/%d"):
|
|
144
|
+
try:
|
|
145
|
+
d = datetime.strptime(raw, fmt)
|
|
146
|
+
return d.replace(year=current_year()).strftime("%Y-%m-%d")
|
|
147
|
+
except ValueError:
|
|
148
|
+
pass
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def normalize_time(raw: str) -> Optional[str]:
|
|
153
|
+
"""
|
|
154
|
+
将时间字符串规范化为 HH:MM,失败返回 None。
|
|
155
|
+
支持:14:30 / 14:30:00 / 9:05
|
|
156
|
+
"""
|
|
157
|
+
raw = raw.strip().replace(":", ":")
|
|
158
|
+
m = re.match(r"(\d{1,2}):(\d{2})", raw)
|
|
159
|
+
if m:
|
|
160
|
+
return f"{int(m.group(1)):02d}:{int(m.group(2)):02d}"
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def add_minutes_to_time(time_str: str, minutes: int) -> str:
|
|
165
|
+
"""HH:MM 加 N 分钟,返回 HH:MM(不跨日边界处理)。"""
|
|
166
|
+
h, m = map(int, time_str.split(":"))
|
|
167
|
+
total = h * 60 + m + minutes
|
|
168
|
+
return f"{(total // 60) % 24:02d}:{total % 60:02d}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ─────────────────────────── 表格解析 ────────────────────────────
|
|
172
|
+
|
|
173
|
+
def _split_md_row(line: str) -> list[str]:
|
|
174
|
+
"""将 Markdown 表格行拆分为单元格列表(去掉首尾 |)。"""
|
|
175
|
+
cells = line.strip().strip("|").split("|")
|
|
176
|
+
return [c.strip() for c in cells]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_separator_row(line: str) -> bool:
|
|
180
|
+
"""判断是否为 Markdown 表格分隔行(如 |---|---|)。"""
|
|
181
|
+
stripped = line.strip()
|
|
182
|
+
# 去掉首尾 | 后,只含 -、:、|、空格
|
|
183
|
+
inner = stripped.strip("|")
|
|
184
|
+
return bool(inner) and bool(re.match(r"^[\s\-:|]+$", inner))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_events_from_markdown(
|
|
188
|
+
content: str,
|
|
189
|
+
user_map: dict[str, str] | None = None,
|
|
190
|
+
default_advance: int = _DEFAULT_ADVANCE_MINUTES,
|
|
191
|
+
default_duration: int = _DEFAULT_DURATION_MINUTES,
|
|
192
|
+
) -> list[dict]:
|
|
193
|
+
"""
|
|
194
|
+
从 Markdown 内容中提取所有日程事件。
|
|
195
|
+
|
|
196
|
+
每个事件输出结构:
|
|
197
|
+
{
|
|
198
|
+
"summary": str, # 必填
|
|
199
|
+
"date": "YYYY-MM-DD", # 必填
|
|
200
|
+
"start": "HH:MM", # 必填
|
|
201
|
+
"end": "HH:MM", # 可选
|
|
202
|
+
"location": str, # 可选
|
|
203
|
+
"description": str, # 备注
|
|
204
|
+
"remind": int, # 提醒分钟数(默认 15)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
跳过:title/date/start 任一缺失或解析失败的行。
|
|
208
|
+
"""
|
|
209
|
+
events: list[dict] = []
|
|
210
|
+
lines = content.splitlines()
|
|
211
|
+
i = 0
|
|
212
|
+
|
|
213
|
+
while i < len(lines):
|
|
214
|
+
line = lines[i]
|
|
215
|
+
|
|
216
|
+
# 找到一个表格的表头行
|
|
217
|
+
if not line.strip().startswith("|"):
|
|
218
|
+
i += 1
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
header_cells = _split_md_row(line)
|
|
222
|
+
|
|
223
|
+
# 下一行应为分隔行
|
|
224
|
+
if i + 1 >= len(lines) or not _is_separator_row(lines[i + 1]):
|
|
225
|
+
i += 1
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
col_map = detect_column_mapping(header_cells, user_map)
|
|
229
|
+
|
|
230
|
+
# title / date / start 是必需字段,至少要有 title
|
|
231
|
+
if "title" not in col_map:
|
|
232
|
+
i += 2
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# 读数据行
|
|
236
|
+
i += 2
|
|
237
|
+
while i < len(lines) and lines[i].strip().startswith("|"):
|
|
238
|
+
cells = _split_md_row(lines[i])
|
|
239
|
+
i += 1
|
|
240
|
+
|
|
241
|
+
def _cell(field: str) -> str:
|
|
242
|
+
idx = col_map.get(field)
|
|
243
|
+
if idx is None or idx >= len(cells):
|
|
244
|
+
return ""
|
|
245
|
+
return cells[idx].strip()
|
|
246
|
+
|
|
247
|
+
title = _cell("title")
|
|
248
|
+
date_raw = _cell("date")
|
|
249
|
+
start_raw= _cell("start")
|
|
250
|
+
|
|
251
|
+
if not title or title in ("-", ""):
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
date_str = normalize_date(date_raw) if date_raw else None
|
|
255
|
+
start_str = normalize_time(start_raw) if start_raw else None
|
|
256
|
+
|
|
257
|
+
if not date_str or not start_str:
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
end_raw = _cell("end")
|
|
261
|
+
end_str = normalize_time(end_raw) if end_raw else None
|
|
262
|
+
if not end_str:
|
|
263
|
+
end_str = add_minutes_to_time(start_str, default_duration)
|
|
264
|
+
|
|
265
|
+
advance_raw = _cell("advance")
|
|
266
|
+
try:
|
|
267
|
+
remind = int(advance_raw) if advance_raw else default_advance
|
|
268
|
+
except ValueError:
|
|
269
|
+
remind = default_advance
|
|
270
|
+
|
|
271
|
+
note = _cell("note")
|
|
272
|
+
location = _cell("location")
|
|
273
|
+
parts_raw = _cell("participants")
|
|
274
|
+
description = " | ".join(filter(None, [note, parts_raw]))
|
|
275
|
+
|
|
276
|
+
events.append({
|
|
277
|
+
"summary": title,
|
|
278
|
+
"date": date_str,
|
|
279
|
+
"start": start_str,
|
|
280
|
+
"end": end_str,
|
|
281
|
+
"location": location,
|
|
282
|
+
"description": description,
|
|
283
|
+
"remind": remind,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
return events
|