From 2838276e7af3b568ef1bacc9245e5ab763560cbb Mon Sep 17 00:00:00 2001 From: tan <3147401920@qq.com> Date: Sat, 7 Feb 2026 22:57:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BC=81=E4=B8=9A=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=8F=90=E9=86=92=E5=92=8C=E6=96=B0=E9=97=BB=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增企业微信 Webhook 推送脚本 (send_wechat.py) - 新增统一通知分发脚本 (send_notify.py),支持邮件+微信并行 - 新增新闻爬虫 (news_crawler.py),支持36氪/知乎/新浪/InfoQ + 自定义RSS - 新增新闻 CLI 入口 (news.py),支持立即推送和定时任务 - 提醒脚本改为走统一通知渠道 - 新增相关配置文件 --- task-reminder/skills/task-reminder/SKILL.md | 84 +++- .../assets/project/scripts/news_crawler.py | 444 ++++++++++++++++++ .../assets/project/scripts/send_notify.py | 105 +++++ .../assets/project/scripts/send_wechat.py | 91 ++++ .../task-reminder/config/news_config.json | 13 + .../task-reminder/config/notify_config.json | 3 + .../task-reminder/config/wechat_config.json | 4 + .../config/wechat_config.json.example | 4 + .../skills/task-reminder/requirements.txt | 2 + .../skills/task-reminder/scripts/news.py | 161 +++++++ .../skills/task-reminder/scripts/remind.py | 2 +- 11 files changed, 911 insertions(+), 2 deletions(-) create mode 100644 task-reminder/skills/task-reminder/assets/project/scripts/news_crawler.py create mode 100644 task-reminder/skills/task-reminder/assets/project/scripts/send_notify.py create mode 100644 task-reminder/skills/task-reminder/assets/project/scripts/send_wechat.py create mode 100644 task-reminder/skills/task-reminder/config/news_config.json create mode 100644 task-reminder/skills/task-reminder/config/notify_config.json create mode 100644 task-reminder/skills/task-reminder/config/wechat_config.json create mode 100644 task-reminder/skills/task-reminder/config/wechat_config.json.example create mode 100644 task-reminder/skills/task-reminder/scripts/news.py diff --git a/task-reminder/skills/task-reminder/SKILL.md b/task-reminder/skills/task-reminder/SKILL.md index ec08b6b..db11d65 100644 --- a/task-reminder/skills/task-reminder/SKILL.md +++ b/task-reminder/skills/task-reminder/SKILL.md @@ -1,6 +1,6 @@ --- name: task-reminder -description: 本地提醒调度技能。用于用户说“提醒我多久之后/在某个时间做什么”等新增提醒需求,尤其适合非技术用户。负责检查服务、自动启动、安装依赖并调用提醒脚本。 +description: 本地提醒调度技能。用于用户说"提醒我多久之后/在某个时间做什么"等新增提醒需求,也支持"推送新闻"、"订阅新闻"等新闻资讯推送。尤其适合非技术用户。负责检查服务、自动启动、安装依赖并调用提醒/新闻脚本。 --- # 提醒调度技能 @@ -103,3 +103,85 @@ python remind.py --at "明天 08:00" 开会 - 成功:确认提醒内容与时间(邮件已发送/将按时发送)。 - 失败:展示脚本错误信息,并提示检查服务或配置。 + +--- + +# 通知渠道 + +系统支持多渠道通知(邮件 + 企业微信),提醒和新闻推送均走统一通知。 + +## 配置文件 + +- `/config/notify_config.json` — 默认渠道列表 +- `/config/wechat_config.json` — 企业微信 Webhook 配置 +- `/config/email_config.json` — 邮件 SMTP 配置 + +## 企业微信配置 + +编辑 `config/wechat_config.json`,填入真实的 Webhook Key: +```json +{ + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "mentioned_list": [] +} +``` + +--- + +# 新闻推送技能 + +## 意图识别 + +当用户说以下内容时,触发新闻推送功能: +- "推送新闻"、"看看今天的新闻"、"新闻早报" → 立即抓取推送 +- "订阅新闻"、"每天推送新闻"、"定时新闻" → 注册定时任务 +- "抓取新闻看看" → 仅抓取预览(不推送) + +## 核心流程 + +1) 确定可用的 Python 命令(同提醒技能)。 +2) 确保服务运行(同提醒技能)。 +3) 根据意图执行对应命令。 + +## 命令 + +### A) 立即推送 + +``` +python news.py now +python news.py now --channels wechat +python news.py now --sources 36kr zhihu_hot +``` + +在此工作目录运行:`/scripts` + +### B) 仅抓取预览(不推送) + +``` +python news.py now --no-push +``` + +### C) 注册定时推送 + +``` +python news.py schedule +python news.py schedule --cron "0 8,20 * * *" +python news.py schedule --channels wechat +``` + +默认 cron: `0 8 * * *`(每天早8点) + +## 支持的新闻源 + +内置源(通过 `config/news_config.json` 启用/禁用): +- `36kr` — 36氪热榜 +- `zhihu_hot` — 知乎热榜 +- `sina_news` — 新浪新闻 +- `infoq` — InfoQ 中文站 + +自定义 RSS 源:在配置文件 `custom_rss` 数组中添加。 + +## 输出给用户 + +- 成功:展示抓取条数和推送结果。 +- 失败:展示错误信息,提示检查服务或网络。 diff --git a/task-reminder/skills/task-reminder/assets/project/scripts/news_crawler.py b/task-reminder/skills/task-reminder/assets/project/scripts/news_crawler.py new file mode 100644 index 0000000..ca4104a --- /dev/null +++ b/task-reminder/skills/task-reminder/assets/project/scripts/news_crawler.py @@ -0,0 +1,444 @@ +""" +新闻爬虫 + 推送脚本 +支持多源抓取、去重、格式化、推送 +""" +import argparse +import hashlib +import json +import sqlite3 +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path +from urllib.parse import quote + +import requests + +try: + import feedparser +except ImportError: + feedparser = None + +try: + from bs4 import BeautifulSoup +except ImportError: + BeautifulSoup = None + + +# --------------------------------------------------------------------------- +# 配置加载 +# --------------------------------------------------------------------------- + +def _find_config(name): + for parent in Path(__file__).resolve().parents: + p = parent / "config" / name + if p.is_file(): + return p + return None + + +def load_news_config(): + p = _find_config("news_config.json") + if p: + with open(p, 'r', encoding='utf-8') as f: + return json.load(f) + return { + "sources": [], + "custom_rss": [], + "max_items_per_source": 10, + "max_total_items": 30, + } + + +# --------------------------------------------------------------------------- +# 去重数据库 +# --------------------------------------------------------------------------- + +def _db_path(): + for parent in Path(__file__).resolve().parents: + data_dir = parent / "data" + if data_dir.is_dir(): + return data_dir / "news_dedup.db" + data_dir = Path(__file__).resolve().parent.parent / "data" + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir / "news_dedup.db" + + +def _init_db(conn): + conn.execute(""" + CREATE TABLE IF NOT EXISTS seen_news ( + md5 TEXT PRIMARY KEY, + title TEXT, + source TEXT, + created_at TEXT + ) + """) + conn.commit() + + +def _cleanup_old(conn, days=7): + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + conn.execute("DELETE FROM seen_news WHERE created_at < ?", (cutoff,)) + conn.commit() + + +def _is_seen(conn, title): + md5 = hashlib.md5(title.encode('utf-8')).hexdigest() + row = conn.execute("SELECT 1 FROM seen_news WHERE md5 = ?", (md5,)).fetchone() + return row is not None + + +def _mark_seen(conn, title, source): + md5 = hashlib.md5(title.encode('utf-8')).hexdigest() + try: + conn.execute( + "INSERT OR IGNORE INTO seen_news (md5, title, source, created_at) VALUES (?, ?, ?, ?)", + (md5, title, source, datetime.now().isoformat()) + ) + conn.commit() + except sqlite3.Error: + pass + + +# --------------------------------------------------------------------------- +# 内置爬虫(JSON API) +# --------------------------------------------------------------------------- + +HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +} + + +def fetch_36kr(limit=10): + """36氪热榜""" + items = [] + try: + url = "https://gateway.36kr.com/api/mis/nav/home/nav/rank/hot" + payload = {"partner_id": "wap", "param": {"siteId": 1, "platformId": 2}} + resp = requests.post(url, json=payload, headers=HEADERS, timeout=10) + data = resp.json() + hot_list = data.get("data", ).get("hotRankList", []) + for item in hot_list[:limit]: + template = item.get("templateMaterial", {}) + title = template.get("widgetTitle", "").strip() + item_id = template.get("itemId", "") + if title: + items.append({ + "title": title, + "url": f"https://36kr.com/p/{item_id}" if item_id else "", + "source": "36氪", + }) + except Exception as e: + print(f"[WARN] 36氪抓取失败: {e}") + return items + + +def fetch_zhihu_hot(limit=10): + """知乎热榜""" + items = [] + try: + url = "https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50" + resp = requests.get(url, headers=HEADERS, timeout=10) + data = resp.json() + for entry in data.get("data", [])[:limit]: + target = entry.get("target", {}) + title = target.get("title", "").strip() + qid = target.get("id", "") + if title: + items.append({ + "title": title, + "url": f"https://www.zhihu.com/question/{qid}" if qid else "", + "source": "知乎热榜", + }) + except Exception as e: + print(f"[WARN] 知乎热榜抓取失败: {e}") + return items + + +def fetch_sina_news(limit=10): + """新浪新闻滚动""" + items = [] + try: + url = "https://feed.mix.sina.com.cn/api/roll/get?pageid=153&lid=2509&k=&num=50&page=1" + resp = requests.get(url, headers=HEADERS, timeout=10) + data = resp.json() + for entry in data.get("result", {}).get("data", [])[:limit]: + title = entry.get("title", "").strip() + link = entry.get("url", "") + if title: + items.append({ + "title": title, + "url": link, + "source": "新浪新闻", + }) + except Exception as e: + print(f"[WARN] 新浪新闻抓取失败: {e}") + return items + + +def fetch_infoq(limit=10): + """InfoQ 中文站""" + items = [] + try: + url = "https://www.infoq.cn/public/v1/article/getList" + payload = {"type": 1, "size": limit} + resp = requests.post(url, json=payload, headers={ + **HEADERS, + "Content-Type": "application/json", + "Referer": "https://www.infoq.cn/" + }, timeout=10) + data = resp.json() + for entry in data.get("data", [])[:limit]: + title = entry.get("article_title", "").strip() + uuid = entry.get("uuid", "") + if title: + items.append({ + "title": title, + "url": f"https://www.infoq.cn/article/{uuid}" if uuid else "", + "source": "InfoQ", + }) + except Exception as e: + print(f"[WARN] InfoQ 抓取失败: {e}") + return items + + +BUILTIN_FETCHERS = { + "36kr": fetch_36kr, + "zhihu_hot": fetch_zhihu_hot, + "sina_news": fetch_sina_news, + "infoq": fetch_infoq, +} + + +# --------------------------------------------------------------------------- +# RSS 抓取 +# --------------------------------------------------------------------------- + +def fetch_rss(name, url, limit=10): + """通过 feedparser 解析 RSS 源""" + if feedparser is None: + print(f"[WARN] feedparser 未安装,跳过 RSS 源: {name}") + return [] + items = [] + try: + feed = feedparser.parse(url) + for entry in feed.entries[:limit]: + title = entry.get("title", "").strip() + link = entry.get("link", "") + if title: + items.append({ + "title": title, + "url": link, + "source": name, + }) + except Exception as e: + print(f"[WARN] RSS 源 {name} 抓取失败: {e}") + return items + + +# --------------------------------------------------------------------------- +# 抓取入口 +# --------------------------------------------------------------------------- + +def crawl_news(sources_filter=None): + """ + 抓取新闻 + + Args: + sources_filter: 仅抓取指定源(列表),None 表示全部 + + Returns: + list[dict]: 去重后的新闻列表 + """ + config = load_news_config() + max_per = config.get("max_items_per_source", 10) + max_total = config.get("max_total_items", 30) + + db = sqlite3.connect(str(_db_path())) + _init_db(db) + _cleanup_old(db) + + all_items = [] + + # 内置源 + for src in config.get("sources", []): + name = src.get("name", "") + if not src.get("enabled", True): + continue + if sources_filter and name not in sources_filter: + continue + fetcher = BUILTIN_FETCHERS.get(name) + if fetcher: + print(f"[INFO] 正在抓取: {name}") + items = fetcher(limit=max_per) + new_items = [] + for item in items: + if not _is_seen(db, item["title"]): + _mark_seen(db, item["title"], item["source"]) + new_items.append(item) + all_items.extend(new_items) + print(f" 获取 {len(items)} 条,新增 {len(new_items)} 条") + + # 自定义 RSS + for rss in config.get("custom_rss", []): + name = rss.get("name", "") + rss_url = rss.get("url", "") + if not rss.get("enabled", True) or not rss_url: + continue + if sources_filter and name not in sources_filter: + continue + print(f"[INFO] 正在抓取 RSS: {name}") + items = fetch_rss(name, rss_url, limit=max_per) + new_items = [] + for item in items: + if not _is_seen(db, item["title"]): + _mark_seen(db, item["title"], item["source"]) + new_items.append(item) + all_items.extend(new_items) + print(f" 获取 {len(items)} 条,新增 {len(new_items)} 条") + + db.close() + + # 截断到最大条数 + if len(all_items) > max_total: + all_items = all_items[:max_total] + + return all_items + + +# --------------------------------------------------------------------------- +# 格式化 +# --------------------------------------------------------------------------- + +def format_digest_plain(items): + """纯文本格式(邮件用)""" + if not items: + return "暂无新的新闻资讯。" + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + lines = [f"新闻早报 - {now_str}", "=" * 40, ""] + + grouped = {} + for item in items: + src = item.get("source", "未知") + grouped.setdefault(src, []).append(item) + + for src, src_items in grouped.items(): + lines.append(f"【{src}】") + for i, item in enumerate(src_items, 1): + title = item["title"] + url = item.get("url", "") + if url: + lines.append(f" {i}. {title}") + lines.append(f" {url}") + else: + lines.append(f" {i}. {title}") + lines.append("") + + lines.append(f"共 {len(items)} 条新资讯") + return "\n".join(lines) + + +def format_digest_markdown(items): + """Markdown 格式(企业微信用)""" + if not items: + return "暂无新的新闻资讯。" + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + lines = [f"## 新闻早报 - {now_str}", ""] + + grouped = {} + for item in items: + src = item.get("source", "未知") + grouped.setdefault(src, []).append(item) + + for src, src_items in grouped.items(): + lines.append(f"### {src}") + for i, item in enumerate(src_items, 1): + title = item["title"] + url = item.get("url", "") + if url: + lines.append(f"> {i}. [{title}]({url})") + else: + lines.append(f"> {i}. {title}") + lines.append("") + + lines.append(f"共 **{len(items)}** 条新资讯") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# 推送 +# --------------------------------------------------------------------------- + +def push_news(items, channels=None): + """ + 推送新闻摘要 + + Args: + items: 新闻列表 + channels: 渠道列表 + """ + scripts_dir = str(Path(__file__).resolve().parent) + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + + try: + from send_notify import send_notify + except ImportError as e: + print(f"[ERROR] 无法导入 send_notify: {e}") + return False + + plain = format_digest_plain(items) + md = format_digest_markdown(items) + + # 根据渠道选择格式 + if channels and channels == ["wechat"]: + content = md + elif channels and channels == ["email"]: + content = plain + else: + content = plain + + now_str = datetime.now().strftime("%Y-%m-%d") + return send_notify( + content=content, + subject=f"新闻早报 - {now_str}", + title=f"新闻早报 - {now_str}", + channels=channels, + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='新闻爬虫 + 推送') + parser.add_argument('--no-push', action='store_true', help='仅抓取不推送(调试)') + parser.add_argument('--channels', nargs='+', default=None, + choices=['email', 'wechat'], help='指定推送渠道') + parser.add_argument('--sources', nargs='+', default=None, + help='仅抓取指定源(如: 36kr zhihu_hot)') + + args = parser.parse_args() + + print("[INFO] 开始抓取新闻...") + items = crawl_news(sources_filter=args.sources) + + if not items: + print("[INFO] 没有新的新闻") + sys.exit(0) + + print(f"\n[INFO] 共获取 {len(items)} 条新闻") + + if args.no_push: + print("\n--- 预览 (纯文本) ---") + print(format_digest_plain(items)) + print("\n--- 预览 (Markdown) ---") + print(format_digest_markdown(items)) + else: + print("[INFO] 正在推送...") + success = push_news(items, channels=args.channels) + sys.exit(0 if success else 1) diff --git a/task-reminder/skills/task-reminder/assets/project/scripts/send_notify.py b/task-reminder/skills/task-reminder/assets/project/scripts/send_notify.py new file mode 100644 index 0000000..d4353ff --- /dev/null +++ b/task-reminder/skills/task-reminder/assets/project/scripts/send_notify.py @@ -0,0 +1,105 @@ +""" +统一通知分发脚本 +支持同时通过邮件和企业微信发送通知 +""" +import argparse +import json +import sys +from pathlib import Path + + +def load_notify_config(): + """加载通知渠道配置""" + candidates = [] + for parent in Path(__file__).resolve().parents: + candidates.append(parent / "config" / "notify_config.json") + + for path in candidates: + if path.is_file(): + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + + return {"channels": ["email"]} + + +def send_notify(content, subject=None, title=None, channels=None): + """ + 统一通知分发 + + Args: + content: 通知内容 + subject: 邮件主题(可选) + title: 微信消息标题(可选) + channels: 渠道列表(可选,默认从配置读取) + + Returns: + bool: 至少一个渠道成功则返回 True + """ + if channels is None: + config = load_notify_config() + channels = config.get("channels", ["email"]) + + results = {} + + if "email" in channels: + try: + from send_email import send_reminder + results["email"] = send_reminder(content, subject) + except ImportError: + # 尝试从同目录导入 + try: + scripts_dir = str(Path(__file__).resolve().parent) + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + from send_email import send_reminder + results["email"] = send_reminder(content, subject) + except Exception as e: + print(f"[WARN] 邮件渠道不可用: {e}") + results["email"] = False + except Exception as e: + print(f"[WARN] 邮件发送失败: {e}") + results["email"] = False + + if "wechat" in channels: + try: + from send_wechat import send_wechat + results["wechat"] = send_wechat(content, title) + except ImportError: + try: + scripts_dir = str(Path(__file__).resolve().parent) + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + from send_wechat import send_wechat + results["wechat"] = send_wechat(content, title) + except Exception as e: + print(f"[WARN] 企业微信渠道不可用: {e}") + results["wechat"] = False + except Exception as e: + print(f"[WARN] 企业微信发送失败: {e}") + results["wechat"] = False + + success_count = sum(1 for v in results.values() if v) + total_count = len(results) + + if success_count == 0 and total_count > 0: + print(f"[ERROR] 所有通知渠道均失败") + return False + + if success_count < total_count: + print(f"[WARN] 部分渠道发送成功 ({success_count}/{total_count})") + + return success_count > 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='统一通知分发') + parser.add_argument('--content', required=True, help='通知内容') + parser.add_argument('--subject', default='贴心提醒', help='邮件主题') + parser.add_argument('--title', default=None, help='微信消息标题') + parser.add_argument('--channels', nargs='+', default=None, + choices=['email', 'wechat'], help='指定通知渠道') + + args = parser.parse_args() + + success = send_notify(args.content, args.subject, args.title, args.channels) + exit(0 if success else 1) diff --git a/task-reminder/skills/task-reminder/assets/project/scripts/send_wechat.py b/task-reminder/skills/task-reminder/assets/project/scripts/send_wechat.py new file mode 100644 index 0000000..ebe9086 --- /dev/null +++ b/task-reminder/skills/task-reminder/assets/project/scripts/send_wechat.py @@ -0,0 +1,91 @@ +""" +企业微信 Webhook 机器人发送脚本 +用于通过企业微信群机器人推送消息 +""" +import argparse +import json +import requests +from pathlib import Path + + +def load_config(): + """加载企业微信配置(优先从 skill 根目录读取)""" + candidates = [] + for parent in Path(__file__).resolve().parents: + candidates.append(parent / "config" / "wechat_config.json") + + for path in candidates: + if path.is_file(): + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + + raise FileNotFoundError("config/wechat_config.json") + + +def send_wechat(content, title=None, msg_type="text"): + """ + 发送企业微信消息 + + Args: + content: 消息内容 + title: 消息标题(仅 markdown 类型有效) + msg_type: 消息类型 text|markdown + """ + try: + config = load_config() + webhook_url = config.get("webhook_url", "") + mentioned_list = config.get("mentioned_list", []) + + if not webhook_url or "YOUR_KEY" in webhook_url: + print("[ERROR] 请先配置企业微信 Webhook URL") + print("编辑 config/wechat_config.json,填入真实的 webhook key") + return False + + if msg_type == "markdown": + body = f"### {title}\n\n{content}" if title else content + payload = { + "msgtype": "markdown", + "markdown": {"content": body} + } + else: + payload = { + "msgtype": "text", + "text": { + "content": f"{title}\n\n{content}" if title else content, + "mentioned_list": mentioned_list + } + } + + resp = requests.post(webhook_url, json=payload, timeout=10) + result = resp.json() + + if result.get("errcode") == 0: + print(f"[OK] 企业微信发送成功") + return True + else: + print(f"[ERROR] 企业微信发送失败: {result.get('errmsg', '未知错误')}") + return False + + except FileNotFoundError: + print("[ERROR] 找不到配置文件 config/wechat_config.json") + print("请先配置企业微信 Webhook 信息") + return False + except requests.exceptions.RequestException as e: + print(f"[ERROR] 请求失败: {e}") + return False + except Exception as e: + print(f"[ERROR] 发送企业微信消息失败: {e}") + return False + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='发送企业微信消息') + parser.add_argument('--content', required=True, help='消息内容') + parser.add_argument('--title', default=None, help='消息标题') + parser.add_argument('--type', dest='msg_type', default='text', + choices=['text', 'markdown'], help='消息类型') + + args = parser.parse_args() + + success = send_wechat(args.content, args.title, args.msg_type) + exit(0 if success else 1) diff --git a/task-reminder/skills/task-reminder/config/news_config.json b/task-reminder/skills/task-reminder/config/news_config.json new file mode 100644 index 0000000..92be57f --- /dev/null +++ b/task-reminder/skills/task-reminder/config/news_config.json @@ -0,0 +1,13 @@ +{ + "sources": [ + {"name": "36kr", "type": "web", "enabled": true}, + {"name": "zhihu_hot", "type": "web", "enabled": true}, + {"name": "sina_news", "type": "web", "enabled": true}, + {"name": "infoq", "type": "web", "enabled": true} + ], + "custom_rss": [ + {"name": "Hacker News", "url": "https://hnrss.org/frontpage", "enabled": false} + ], + "max_items_per_source": 10, + "max_total_items": 30 +} diff --git a/task-reminder/skills/task-reminder/config/notify_config.json b/task-reminder/skills/task-reminder/config/notify_config.json new file mode 100644 index 0000000..f00477d --- /dev/null +++ b/task-reminder/skills/task-reminder/config/notify_config.json @@ -0,0 +1,3 @@ +{ + "channels": ["email", "wechat"] +} diff --git a/task-reminder/skills/task-reminder/config/wechat_config.json b/task-reminder/skills/task-reminder/config/wechat_config.json new file mode 100644 index 0000000..23a14ea --- /dev/null +++ b/task-reminder/skills/task-reminder/config/wechat_config.json @@ -0,0 +1,4 @@ +{ + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "mentioned_list": [] +} diff --git a/task-reminder/skills/task-reminder/config/wechat_config.json.example b/task-reminder/skills/task-reminder/config/wechat_config.json.example new file mode 100644 index 0000000..100fc1d --- /dev/null +++ b/task-reminder/skills/task-reminder/config/wechat_config.json.example @@ -0,0 +1,4 @@ +{ + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "mentioned_list": ["@all"] +} diff --git a/task-reminder/skills/task-reminder/requirements.txt b/task-reminder/skills/task-reminder/requirements.txt index f636cd9..9a60f2c 100644 --- a/task-reminder/skills/task-reminder/requirements.txt +++ b/task-reminder/skills/task-reminder/requirements.txt @@ -3,3 +3,5 @@ uvicorn>=0.27.0 apscheduler>=3.10.4 requests>=2.31.0 pydantic>=2.5.0 +beautifulsoup4>=4.12.0 +feedparser>=6.0.0 diff --git a/task-reminder/skills/task-reminder/scripts/news.py b/task-reminder/skills/task-reminder/scripts/news.py new file mode 100644 index 0000000..4de886a --- /dev/null +++ b/task-reminder/skills/task-reminder/scripts/news.py @@ -0,0 +1,161 @@ +""" +新闻 CLI 入口 +支持手动触发抓取推送 / 注册定时任务 +""" +import argparse +import json +import sys +from pathlib import Path +from datetime import datetime + +import requests + + +def _load_api_base(): + for parent in Path(__file__).resolve().parents: + config_path = parent / "config" / "app_config.json" + if config_path.is_file(): + try: + cfg = json.loads(config_path.read_text(encoding="utf-8")) + return cfg.get("web_api_base") or cfg.get("api_base") + except Exception: + return None + return None + + +API_BASE_URL = _load_api_base() or "http://127.0.0.1:8000" + + +def cmd_now(args): + """立即抓取并推送新闻""" + # 定位 news_crawler.py + skill_root = None + for parent in Path(__file__).resolve().parents: + if (parent / "assets" / "project" / "scripts" / "news_crawler.py").is_file(): + skill_root = parent + break + + if not skill_root: + print("[ERROR] 找不到 news_crawler.py") + sys.exit(1) + + # 将 scripts 目录加入 path 以便导入 + scripts_dir = str(skill_root / "assets" / "project" / "scripts") + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + + from news_crawler import crawl_news, push_news, format_digest_plain, format_digest_markdown + + print("[INFO] 开始抓取新闻...") + items = crawl_news(sources_filter=args.sources) + + if not items: + print("[INFO] 没有新的新闻") + return + + print(f"[INFO] 共获取 {len(items)} 条新闻") + + if args.no_push: + print("\n--- 预览 ---") + print(format_digest_plain(items)) + else: + print("[INFO] 正在推送...") + success = push_news(items, channels=args.channels) + if success: + print("[OK] 新闻推送完成") + else: + print("[ERROR] 新闻推送失败") + sys.exit(1) + + +def cmd_schedule(args): + """注册定时新闻推送任务到服务器""" + cron_expr = args.cron + channels = args.channels + + # 构建脚本参数 + script_args = [] + if channels: + script_args.extend(["--channels"] + channels) + + task_id = f"news_schedule_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + + data = { + "task_id": task_id, + "task_type": "cron", + "cron_expression": cron_expr, + "script_path": "scripts/news_crawler.py", + "script_args": script_args, + } + + try: + response = requests.post(f"{API_BASE_URL}/tasks", json=data, timeout=5) + + if response.status_code == 200: + result = response.json() + print("[OK] 定时新闻任务已创建") + print(f" 任务ID: {result['task_id']}") + print(f" Cron: {cron_expr}") + if channels: + print(f" 渠道: {', '.join(channels)}") + print(f"\n可在 Web UI 查看任务状态") + return + + error = response.json() + print(f"[ERROR] 创建失败: {error.get('detail', '未知错误')}") + sys.exit(1) + + except requests.exceptions.ConnectionError: + print("[ERROR] 无法连接到服务器") + print("请确保服务器正在运行: py start_server.py") + sys.exit(1) + except Exception as e: + print(f"[ERROR] {e}") + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser( + description="新闻推送 CLI", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python news.py now 立即抓取并推送 + python news.py now --no-push 仅抓取预览 + python news.py now --channels wechat 仅推送到企业微信 + python news.py now --sources 36kr zhihu_hot 仅抓取指定源 + python news.py schedule 注册每天早8点定时推送 + python news.py schedule --cron "0 8,20 * * *" 自定义 cron + """ + ) + + subparsers = parser.add_subparsers(dest="command", help="子命令") + + # now 子命令 + now_parser = subparsers.add_parser("now", help="立即抓取并推送") + now_parser.add_argument('--no-push', action='store_true', help='仅抓取不推送') + now_parser.add_argument('--channels', nargs='+', default=None, + choices=['email', 'wechat'], help='指定推送渠道') + now_parser.add_argument('--sources', nargs='+', default=None, + help='仅抓取指定源') + + # schedule 子命令 + sched_parser = subparsers.add_parser("schedule", help="注册定时推送任务") + sched_parser.add_argument('--cron', default="0 8 * * *", + help='Cron 表达式(默认: 0 8 * * *,每天早8点)') + sched_parser.add_argument('--channels', nargs='+', default=None, + choices=['email', 'wechat'], help='指定推送渠道') + + args = parser.parse_args() + + if args.command == "now": + cmd_now(args) + elif args.command == "schedule": + cmd_schedule(args) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/task-reminder/skills/task-reminder/scripts/remind.py b/task-reminder/skills/task-reminder/scripts/remind.py index b73265c..6cc50a4 100644 --- a/task-reminder/skills/task-reminder/scripts/remind.py +++ b/task-reminder/skills/task-reminder/scripts/remind.py @@ -162,7 +162,7 @@ def _post_task(task_id: str, execute_at: str, content: str, time_desc: str): 'task_id': task_id, 'task_type': 'date', 'execute_at': execute_at, - 'script_path': 'scripts/send_email.py', + 'script_path': 'scripts/send_notify.py', 'script_args': ['--content', content] }