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.
- 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/flight-booking/skill.json +9 -2
- package/skills/flight-booking/src/scripts/flight_booking.py +2 -1
- 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,246 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ROC(Regular Operation Calendar)定期事件提醒检查。
|
|
4
|
+
|
|
5
|
+
每天自动运行,检查今日是否有需要提醒的 ROC 事件,输出 JSON 供 Agent 处理。
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
python3 apps/check_roc.py # 检查今天,输出 JSON
|
|
9
|
+
python3 apps/check_roc.py --upcoming 30 # 列出未来 30 天内的提醒
|
|
10
|
+
python3 apps/check_roc.py --list # 列出今年全部事件及提醒日期
|
|
11
|
+
python3 apps/check_roc.py --dry-run # 只计算,不写状态文件
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime, timedelta
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
# ── 路径配置 ──────────────────────────────────────────────────────────
|
|
26
|
+
_SKILL_BASE = Path(__file__).resolve().parent.parent
|
|
27
|
+
_CONFIG_FILE = _SKILL_BASE / "config" / "roc_events.yaml"
|
|
28
|
+
_STATE_FILE = _SKILL_BASE / "data" / "roc_state.json"
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, str(_SKILL_BASE))
|
|
31
|
+
|
|
32
|
+
from config.settings import TIMEZONE
|
|
33
|
+
from services.calendar_backend import active_provider
|
|
34
|
+
from services.datetime_utils import current_date_str, current_year, now_local
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── 日期计算 ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def _nth_weekday(year: int, month: int, weekday: int, n: int) -> datetime:
|
|
40
|
+
"""返回 year/month 中第 n 个 weekday(0=周一…6=周日)。"""
|
|
41
|
+
first = datetime(year, month, 1)
|
|
42
|
+
offset = (weekday - first.weekday()) % 7
|
|
43
|
+
first_match = first + timedelta(days=offset)
|
|
44
|
+
return first_match + timedelta(weeks=n - 1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_WEEKDAY_MAP = {"周一": 0, "周二": 1, "周三": 2, "周四": 3, "周五": 4, "周六": 5, "周日": 6}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_event_date(rule: str, year: int, spring: dict) -> datetime | None:
|
|
51
|
+
"""将 rule 字符串解析为具体日期。"""
|
|
52
|
+
|
|
53
|
+
# 每年M月D日
|
|
54
|
+
m = re.fullmatch(r"每年(\d+)月(\d+)日", rule.strip())
|
|
55
|
+
if m:
|
|
56
|
+
try:
|
|
57
|
+
return datetime(year, int(m.group(1)), int(m.group(2)))
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# 每年M月第N个周X
|
|
62
|
+
m = re.search(r"每年(\d+)月第(\d+)个(周[一二三四五六日])", rule)
|
|
63
|
+
if m:
|
|
64
|
+
month = int(m.group(1))
|
|
65
|
+
n = int(m.group(2))
|
|
66
|
+
weekday = _WEEKDAY_MAP.get(m.group(3))
|
|
67
|
+
if weekday is not None:
|
|
68
|
+
return _nth_weekday(year, month, weekday, n)
|
|
69
|
+
|
|
70
|
+
# 春节相关(从 spring_festival 表查)
|
|
71
|
+
sf = spring.get(year, {})
|
|
72
|
+
if "春节前最后工作日" in rule:
|
|
73
|
+
d = sf.get("last_workday")
|
|
74
|
+
return datetime.strptime(d, "%Y-%m-%d") if d else None
|
|
75
|
+
if "春节后第一个工作日" in rule:
|
|
76
|
+
d = sf.get("first_workday")
|
|
77
|
+
return datetime.strptime(d, "%Y-%m-%d") if d else None
|
|
78
|
+
|
|
79
|
+
# 5.1 假期后第一个工作日(固定 5-06,如变动手工在 YAML 配置)
|
|
80
|
+
if "5.1假期后第一个工作日" in rule:
|
|
81
|
+
return datetime(year, 5, 6)
|
|
82
|
+
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── 数据加载 ──────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def _load_config() -> dict:
|
|
89
|
+
with open(_CONFIG_FILE, encoding="utf-8") as f:
|
|
90
|
+
return yaml.safe_load(f)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_state() -> dict:
|
|
94
|
+
try:
|
|
95
|
+
return json.loads(_STATE_FILE.read_text(encoding="utf-8"))
|
|
96
|
+
except Exception:
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _save_state(state: dict) -> None:
|
|
101
|
+
_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
_STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── 核心逻辑 ──────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def build_year_events(year: int, cfg: dict) -> list[dict]:
|
|
108
|
+
"""计算指定年份所有 ROC 事件的发生日期和提醒日期。"""
|
|
109
|
+
spring = cfg.get("spring_festival", {})
|
|
110
|
+
result = []
|
|
111
|
+
for ev in cfg.get("events", []):
|
|
112
|
+
event_dt = resolve_event_date(ev["rule"], year, spring)
|
|
113
|
+
if event_dt is None:
|
|
114
|
+
continue
|
|
115
|
+
lead = int(ev.get("lead_days", 0))
|
|
116
|
+
reminder_dt = event_dt - timedelta(days=lead)
|
|
117
|
+
result.append({
|
|
118
|
+
"id": ev["id"],
|
|
119
|
+
"name": ev["name"],
|
|
120
|
+
"rule": ev["rule"],
|
|
121
|
+
"event_date": event_dt.strftime("%Y-%m-%d"),
|
|
122
|
+
"reminder_date": reminder_dt.strftime("%Y-%m-%d"),
|
|
123
|
+
"reminder_time": ev.get("reminder_time", "08:00"),
|
|
124
|
+
"lead_days": lead,
|
|
125
|
+
"description": ev.get("description", ""),
|
|
126
|
+
})
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_today(year_events: list[dict], today: str, state: dict) -> list[dict]:
|
|
131
|
+
"""返回今天需要提醒且尚未发送过提醒的事件。"""
|
|
132
|
+
hits = []
|
|
133
|
+
for ev in year_events:
|
|
134
|
+
if ev["reminder_date"] != today:
|
|
135
|
+
continue
|
|
136
|
+
# 若同一事件今天已提醒过,跳过
|
|
137
|
+
if state.get(ev["id"]) == today:
|
|
138
|
+
continue
|
|
139
|
+
hits.append(ev)
|
|
140
|
+
return hits
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def upcoming_events(year_events: list[dict], today: str, days: int) -> list[dict]:
|
|
144
|
+
"""返回未来 days 天内(含今天)提醒日期内的事件,按提醒日期升序。"""
|
|
145
|
+
today_dt = datetime.strptime(today, "%Y-%m-%d")
|
|
146
|
+
cutoff_dt = today_dt + timedelta(days=days)
|
|
147
|
+
result = []
|
|
148
|
+
for ev in year_events:
|
|
149
|
+
rd = datetime.strptime(ev["reminder_date"], "%Y-%m-%d")
|
|
150
|
+
if today_dt <= rd <= cutoff_dt:
|
|
151
|
+
ev = dict(ev)
|
|
152
|
+
ev["days_until_reminder"] = (rd - today_dt).days
|
|
153
|
+
ed = datetime.strptime(ev["event_date"], "%Y-%m-%d")
|
|
154
|
+
ev["days_until_event"] = (ed - today_dt).days
|
|
155
|
+
result.append(ev)
|
|
156
|
+
result.sort(key=lambda x: x["reminder_date"])
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def format_reminder_message(ev: dict) -> str:
|
|
161
|
+
ed = datetime.strptime(ev["event_date"], "%Y-%m-%d")
|
|
162
|
+
today = now_local(TIMEZONE).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
|
|
163
|
+
days = (ed - today).days
|
|
164
|
+
lines = [
|
|
165
|
+
f"📅 **ROC 提醒:{ev['name']}**",
|
|
166
|
+
f"📆 事件日期:{ev['event_date']}({days} 天后)",
|
|
167
|
+
]
|
|
168
|
+
if ev.get("description"):
|
|
169
|
+
lines.append(f"📝 {ev['description']}")
|
|
170
|
+
return "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── CLI 入口 ──────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
def main() -> None:
|
|
176
|
+
parser = argparse.ArgumentParser(description="ROC 定期事件提醒检查")
|
|
177
|
+
parser.add_argument("--upcoming", type=int, metavar="DAYS",
|
|
178
|
+
help="列出未来 N 天内即将提醒的事件")
|
|
179
|
+
parser.add_argument("--list", action="store_true",
|
|
180
|
+
help="列出今年全部 ROC 事件(事件日期 + 提醒日期)")
|
|
181
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
182
|
+
help="只计算,不写状态文件")
|
|
183
|
+
parser.add_argument("--date", default=None,
|
|
184
|
+
help="模拟日期 YYYY-MM-DD(默认今天,测试用)")
|
|
185
|
+
args = parser.parse_args()
|
|
186
|
+
|
|
187
|
+
today = args.date or current_date_str(TIMEZONE)
|
|
188
|
+
year = int(today[:4])
|
|
189
|
+
|
|
190
|
+
cfg = _load_config()
|
|
191
|
+
year_events = build_year_events(year, cfg)
|
|
192
|
+
state = _load_state()
|
|
193
|
+
|
|
194
|
+
# 当年 ROC 事件若尚未同步到 Google Calendar,自动触发一次
|
|
195
|
+
if active_provider() == "google":
|
|
196
|
+
try:
|
|
197
|
+
from apps.sync_roc_to_gcal import is_year_synced, sync_year
|
|
198
|
+
if not is_year_synced(year):
|
|
199
|
+
print(f"[check_roc] {year} 年 ROC 事件未同步到 GCal,自动同步中…", flush=True)
|
|
200
|
+
result = sync_year(year, verbose=False)
|
|
201
|
+
added = len(result.get("added", []))
|
|
202
|
+
print(f"[check_roc] GCal 同步完成:新增 {added} 个全天事件", flush=True)
|
|
203
|
+
except Exception as e:
|
|
204
|
+
# 同步失败不影响提醒功能
|
|
205
|
+
print(f"[check_roc] GCal 同步跳过({e})", flush=True)
|
|
206
|
+
|
|
207
|
+
# ── --list ────────────────────────────────────────────────────────
|
|
208
|
+
if args.list:
|
|
209
|
+
out = {"year": year, "events": year_events}
|
|
210
|
+
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# ── --upcoming ────────────────────────────────────────────────────
|
|
214
|
+
if args.upcoming is not None:
|
|
215
|
+
evs = upcoming_events(year_events, today, args.upcoming)
|
|
216
|
+
print(json.dumps({"today": today, "upcoming": evs}, ensure_ascii=False, indent=2))
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# ── 默认:检查今天 ────────────────────────────────────────────────
|
|
220
|
+
hits = check_today(year_events, today, state)
|
|
221
|
+
|
|
222
|
+
output = {
|
|
223
|
+
"ok": True,
|
|
224
|
+
"today": today,
|
|
225
|
+
"reminders": [],
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for ev in hits:
|
|
229
|
+
output["reminders"].append({
|
|
230
|
+
"id": ev["id"],
|
|
231
|
+
"name": ev["name"],
|
|
232
|
+
"event_date": ev["event_date"],
|
|
233
|
+
"description": ev["description"],
|
|
234
|
+
"message": format_reminder_message(ev),
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
if not args.dry_run and hits:
|
|
238
|
+
for ev in hits:
|
|
239
|
+
state[ev["id"]] = today
|
|
240
|
+
_save_state(state)
|
|
241
|
+
|
|
242
|
+
print(json.dumps(output, ensure_ascii=False, indent=2))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
main()
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
每日计划生成器。
|
|
4
|
+
|
|
5
|
+
执行时机:每天 07:50(由 OpenClaw Cron 触发)
|
|
6
|
+
|
|
7
|
+
流程:
|
|
8
|
+
1. 从 config/task_registry.yaml 读取固定任务,注册到 OpenClaw Cron
|
|
9
|
+
2. 从 Google Calendar 读取今天(或今天+明天)日程
|
|
10
|
+
3. 为每个日程生成一次性提醒任务,注册到 OpenClaw Cron
|
|
11
|
+
4. 生成 daily/cron-YYYYMMDD.md 可读记录
|
|
12
|
+
|
|
13
|
+
用法:
|
|
14
|
+
python3 apps/generate_daily_plan.py
|
|
15
|
+
python3 apps/generate_daily_plan.py --days 2 # 今天+明天
|
|
16
|
+
python3 apps/generate_daily_plan.py --dry-run # 只生成 md,不注册 cron
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
try:
|
|
28
|
+
from zoneinfo import ZoneInfo
|
|
29
|
+
except ImportError:
|
|
30
|
+
from backports.zoneinfo import ZoneInfo
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
# ── sys.path 保障(以脚本方式直接运行时)────────────────────────
|
|
35
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
36
|
+
|
|
37
|
+
from config.settings import (
|
|
38
|
+
TIMEZONE,
|
|
39
|
+
DAILY_DIR, TASK_REGISTRY_FILE, REMINDER_RULES_FILE,
|
|
40
|
+
SKILL_BASE,
|
|
41
|
+
)
|
|
42
|
+
from gcal.models import CalendarEvent
|
|
43
|
+
from services.calendar_backend import active_provider, list_event_views
|
|
44
|
+
from services.time_window import get_today_window, get_today_and_tomorrow_window, now_tz
|
|
45
|
+
from services.reminder_planner import plan_reminders, ReminderPlan
|
|
46
|
+
from services.task_builder import (
|
|
47
|
+
build_periodic_silent_task,
|
|
48
|
+
build_reminder_task,
|
|
49
|
+
build_reminder_task_name,
|
|
50
|
+
build_report_task,
|
|
51
|
+
)
|
|
52
|
+
from services.conflict_detector import detect_conflicts, ConflictPair
|
|
53
|
+
from services.job_store import append_job_if_absent, list_job_names
|
|
54
|
+
from services.runtime_utils import expand_command_vars
|
|
55
|
+
|
|
56
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ─────────────────────────── task registry ────────────────────────
|
|
63
|
+
|
|
64
|
+
def load_task_registry() -> list[dict]:
|
|
65
|
+
try:
|
|
66
|
+
with open(TASK_REGISTRY_FILE, encoding="utf-8") as f:
|
|
67
|
+
data = yaml.safe_load(f) or {}
|
|
68
|
+
provider = active_provider()
|
|
69
|
+
return [
|
|
70
|
+
task for task in data.get("tasks", [])
|
|
71
|
+
if task.get("requires_provider", "any") in {"", "any", provider}
|
|
72
|
+
]
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"加载 task_registry.yaml 失败: {e}")
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
def parse_fixed_task(task: dict, today: datetime) -> dict | None:
|
|
78
|
+
"""
|
|
79
|
+
将 task_registry 中的一条任务转换为 OpenClaw job payload。
|
|
80
|
+
返回 None 表示跳过(周期任务不在每日 at 中创建)。
|
|
81
|
+
"""
|
|
82
|
+
name = task.get("name", "")
|
|
83
|
+
kind = task.get("type", "report")
|
|
84
|
+
message = expand_command_vars(task.get("message", task.get("command", "")))
|
|
85
|
+
sched = task.get("schedule", {})
|
|
86
|
+
delete = task.get("delete_after_run", True)
|
|
87
|
+
|
|
88
|
+
if sched.get("kind") == "cron":
|
|
89
|
+
# 周期任务:用 build_periodic_silent_task 或 build_report_task
|
|
90
|
+
expr = sched.get("expr", "")
|
|
91
|
+
tz = sched.get("tz", TIMEZONE)
|
|
92
|
+
if kind == "silent":
|
|
93
|
+
return build_periodic_silent_task(name, expr, message, tz)
|
|
94
|
+
else:
|
|
95
|
+
return build_report_task(
|
|
96
|
+
name, message,
|
|
97
|
+
cron_expr=expr, tz=tz,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
elif sched.get("kind") == "at":
|
|
101
|
+
# 一次性任务:解析 HH:MM,换算 UTC
|
|
102
|
+
time_str = sched.get("time", "")
|
|
103
|
+
try:
|
|
104
|
+
h, m = map(int, time_str.split(":"))
|
|
105
|
+
except ValueError:
|
|
106
|
+
logger.warning(f"无法解析时间 '{time_str}',跳过任务 {name}")
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
zone = ZoneInfo(TIMEZONE)
|
|
110
|
+
bj_time = today.astimezone(zone).replace(hour=h, minute=m, second=0, microsecond=0)
|
|
111
|
+
|
|
112
|
+
if kind == "silent":
|
|
113
|
+
# 静默一次性:用 agentTurn + delivery:none
|
|
114
|
+
return {
|
|
115
|
+
"name": name,
|
|
116
|
+
"schedule": {"kind": "at", "at": _utc_str(bj_time)},
|
|
117
|
+
"sessionTarget": "isolated",
|
|
118
|
+
"payload": {"kind": "agentTurn", "message": message},
|
|
119
|
+
"delivery": {"mode": "none"},
|
|
120
|
+
"deleteAfterRun": delete,
|
|
121
|
+
}
|
|
122
|
+
else:
|
|
123
|
+
return build_report_task(
|
|
124
|
+
name, message,
|
|
125
|
+
at_utc=bj_time, delete_after_run=delete,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ─────────────────────────── cron 注册 ────────────────────────────
|
|
132
|
+
|
|
133
|
+
def _utc_str(dt: datetime) -> str:
|
|
134
|
+
from datetime import timezone
|
|
135
|
+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _get_existing_task_names() -> set[str]:
|
|
139
|
+
"""从 jobs.json 读取已存在的任务名称"""
|
|
140
|
+
try:
|
|
141
|
+
return list_job_names()
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.warning(f"读取现有 cron 任务失败: {e}")
|
|
144
|
+
return set()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def register_cron_task(payload: dict, dry_run: bool, existing: set[str]) -> bool:
|
|
148
|
+
"""注册单个任务到 OpenClaw Cron(直接操作 jobs.json)。"""
|
|
149
|
+
name = payload.get("name", "")
|
|
150
|
+
if name in existing:
|
|
151
|
+
logger.info(f"⏭️ 任务已存在,跳过: {name}")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
if dry_run:
|
|
155
|
+
logger.info(f"[dry-run] 将注册: {name}")
|
|
156
|
+
existing.add(name)
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
added = append_job_if_absent(payload)
|
|
161
|
+
if added:
|
|
162
|
+
existing.add(name)
|
|
163
|
+
logger.info(f"✅ 已注册: {name}")
|
|
164
|
+
return True
|
|
165
|
+
logger.info(f"⏭️ 任务已存在,跳过: {name}")
|
|
166
|
+
return False
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"❌ 注册异常 ({name}): {e}")
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ─────────────────────────── markdown 生成 ────────────────────────
|
|
173
|
+
|
|
174
|
+
def _load_reminder_rules_text() -> str:
|
|
175
|
+
"""从 reminder_rules.yaml 动态生成提醒规则说明文字。"""
|
|
176
|
+
try:
|
|
177
|
+
with open(REMINDER_RULES_FILE, encoding="utf-8") as f:
|
|
178
|
+
data = yaml.safe_load(f) or {}
|
|
179
|
+
lead = data.get("lead_minutes", {})
|
|
180
|
+
type_cn = {"meeting": "会议", "sport": "运动", "meal": "饭局/宴会"}
|
|
181
|
+
lines = []
|
|
182
|
+
for key, cn in type_cn.items():
|
|
183
|
+
mins = lead.get(key)
|
|
184
|
+
if mins is not None:
|
|
185
|
+
lines.append(f"- {cn}:提前 **{mins} 分钟** 提醒")
|
|
186
|
+
default = data.get("default_lead_minutes")
|
|
187
|
+
if default:
|
|
188
|
+
lines.append(f"- 其他:提前 **{default} 分钟** 提醒(默认)")
|
|
189
|
+
return "\n".join(lines) if lines else "- 默认:提前 5 分钟提醒"
|
|
190
|
+
except Exception:
|
|
191
|
+
return "- 会议:提前 **2 分钟** 提醒\n- 运动:提前 **15 分钟** 提醒\n- 饭局/宴会:提前 **15 分钟** 提醒"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def generate_markdown(
|
|
195
|
+
today: datetime,
|
|
196
|
+
fixed_tasks: list[dict],
|
|
197
|
+
events: list[CalendarEvent],
|
|
198
|
+
reminder_plans: list[ReminderPlan],
|
|
199
|
+
conflicts: list[ConflictPair] | None = None,
|
|
200
|
+
) -> str:
|
|
201
|
+
date_str = today.strftime("%Y-%m-%d")
|
|
202
|
+
weekday = WEEKDAY_CN[today.weekday()]
|
|
203
|
+
lines = [f"# Cron 任务记录 - {date_str} {weekday}", ""]
|
|
204
|
+
|
|
205
|
+
# 提醒规则说明(动态读取)
|
|
206
|
+
lines += ["## 提醒规则", "", _load_reminder_rules_text(), ""]
|
|
207
|
+
|
|
208
|
+
# 冲突告警(有冲突时显示)
|
|
209
|
+
if conflicts:
|
|
210
|
+
lines += ["## ⚠️ 日程冲突", ""]
|
|
211
|
+
for p in conflicts:
|
|
212
|
+
lines.append(f"- {p.format_text()}")
|
|
213
|
+
lines.append("")
|
|
214
|
+
|
|
215
|
+
# 重复性任务
|
|
216
|
+
lines += [
|
|
217
|
+
"## 重复性任务汇总",
|
|
218
|
+
"",
|
|
219
|
+
"| 序号 | 任务 | 执行时间 | 类型 | 任务说明 |",
|
|
220
|
+
"|------|------|----------|------|----------|",
|
|
221
|
+
]
|
|
222
|
+
for i, t in enumerate(fixed_tasks, 1):
|
|
223
|
+
sched = t.get("schedule", {})
|
|
224
|
+
if sched.get("kind") == "cron":
|
|
225
|
+
time_display = f"cron: {sched.get('expr', '-')}"
|
|
226
|
+
else:
|
|
227
|
+
time_display = sched.get("time", "-")
|
|
228
|
+
lines.append(
|
|
229
|
+
f"| {i} | {t.get('name','-')} | {time_display} | {t.get('type','-')} | {t.get('description','-')} |"
|
|
230
|
+
)
|
|
231
|
+
lines.append("")
|
|
232
|
+
|
|
233
|
+
# 当日一次性提醒
|
|
234
|
+
lines += ["## 当日一次性提醒", ""]
|
|
235
|
+
if reminder_plans:
|
|
236
|
+
lines += [
|
|
237
|
+
"| 序号 | 日程 | 地点 | 开始时间 | 提醒时间 | 类型 | 状态 |",
|
|
238
|
+
"|------|------|------|----------|----------|------|------|",
|
|
239
|
+
]
|
|
240
|
+
for i, p in enumerate(reminder_plans, 1):
|
|
241
|
+
start_str = p.event.start.strftime("%H:%M")
|
|
242
|
+
remind_str = p.remind_at.strftime("%H:%M")
|
|
243
|
+
status = "✅ 将创建" if p.should_schedule else "⏩ 已过期"
|
|
244
|
+
location = p.event.location or "-"
|
|
245
|
+
lines.append(
|
|
246
|
+
f"| {i} | {p.event.summary} | {location} | {start_str} | {remind_str} | {p.event_type} | {status} |"
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
lines.append("> 今日无日程提醒任务")
|
|
250
|
+
lines.append("")
|
|
251
|
+
|
|
252
|
+
# 今日日程概览
|
|
253
|
+
lines += ["## 今日日程概览", ""]
|
|
254
|
+
if events:
|
|
255
|
+
for e in events:
|
|
256
|
+
t = e.start.strftime("%H:%M") if not e.is_all_day else "全天"
|
|
257
|
+
loc = f" 📍 {e.location}" if e.location else ""
|
|
258
|
+
lines.append(f"- {t} {e.summary}{loc}")
|
|
259
|
+
else:
|
|
260
|
+
lines.append("- 暂无日程")
|
|
261
|
+
lines.append("")
|
|
262
|
+
|
|
263
|
+
lines += [
|
|
264
|
+
"## 变更记录",
|
|
265
|
+
"",
|
|
266
|
+
f"- {today.strftime('%H:%M')} 自动生成今日 Cron 任务文件",
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ─────────────────────────── main ─────────────────────────────────
|
|
273
|
+
|
|
274
|
+
def main():
|
|
275
|
+
parser = argparse.ArgumentParser(description="每日计划生成器")
|
|
276
|
+
parser.add_argument("--days", type=int, default=1,
|
|
277
|
+
help="拉取日程天数(1=今天,2=今天+明天)")
|
|
278
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
279
|
+
help="只生成 md 文件,不注册 cron 任务")
|
|
280
|
+
args = parser.parse_args()
|
|
281
|
+
|
|
282
|
+
now = now_tz(TIMEZONE)
|
|
283
|
+
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
284
|
+
|
|
285
|
+
# ── 1. 拉取日程 ────────────────────────────────────────────────
|
|
286
|
+
if args.days >= 2:
|
|
287
|
+
t_min, t_max = get_today_and_tomorrow_window(TIMEZONE)
|
|
288
|
+
else:
|
|
289
|
+
t_min, t_max = get_today_window(TIMEZONE)
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
views = list_event_views(t_min, t_max)
|
|
293
|
+
events = [item.event for item in views]
|
|
294
|
+
logger.info(f"拉取到 {len(events)} 个事件")
|
|
295
|
+
except Exception as e:
|
|
296
|
+
logger.error(f"拉取日程失败: {e}")
|
|
297
|
+
events = []
|
|
298
|
+
|
|
299
|
+
# ── 2. 生成提醒计划 ─────────────────────────────────────────────
|
|
300
|
+
plans = plan_reminders(events, now=now, tz=TIMEZONE)
|
|
301
|
+
|
|
302
|
+
# ── 3. 冲突检测 ─────────────────────────────────────────────────
|
|
303
|
+
conflicts = detect_conflicts(events)
|
|
304
|
+
if conflicts:
|
|
305
|
+
logger.warning(f"⚠️ 检测到 {len(conflicts)} 处日程冲突")
|
|
306
|
+
for c in conflicts:
|
|
307
|
+
logger.warning(f" {c.format_text()}")
|
|
308
|
+
|
|
309
|
+
# ── 4. 加载固定任务 ─────────────────────────────────────────────
|
|
310
|
+
task_defs = load_task_registry()
|
|
311
|
+
|
|
312
|
+
# ── 5. 注册 cron 任务 ──────────────────────────────────────────
|
|
313
|
+
existing = set() if args.dry_run else _get_existing_task_names()
|
|
314
|
+
|
|
315
|
+
# 固定任务
|
|
316
|
+
for t in task_defs:
|
|
317
|
+
payload = parse_fixed_task(t, today)
|
|
318
|
+
if payload:
|
|
319
|
+
register_cron_task(payload, args.dry_run, existing)
|
|
320
|
+
|
|
321
|
+
# 日程提醒
|
|
322
|
+
for p in plans:
|
|
323
|
+
if not p.should_schedule:
|
|
324
|
+
logger.info(f"⏩ 提醒时间已过,跳过: {p.event.summary}")
|
|
325
|
+
continue
|
|
326
|
+
payload = build_reminder_task(
|
|
327
|
+
name = build_reminder_task_name(p.event.summary, p.event.start, p.event.id),
|
|
328
|
+
remind_at_utc= p.remind_at_utc,
|
|
329
|
+
message = p.reminder_message(),
|
|
330
|
+
)
|
|
331
|
+
register_cron_task(payload, args.dry_run, existing)
|
|
332
|
+
|
|
333
|
+
# ── 6. 生成 md 文件 ─────────────────────────────────────────────
|
|
334
|
+
DAILY_DIR.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
md_path = DAILY_DIR / f"cron-{today.strftime('%Y%m%d')}.md"
|
|
336
|
+
md_content = generate_markdown(today, task_defs, events, plans, conflicts)
|
|
337
|
+
md_path.write_text(md_content, encoding="utf-8")
|
|
338
|
+
logger.info(f"📄 已生成: {md_path}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
main()
|