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,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