From 6ea2a4e4c6b3955569e6dfe726e3255ba28cd499 Mon Sep 17 00:00:00 2001 From: yumao Date: Fri, 17 Jul 2026 18:12:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=97=A9=E6=8A=A5=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=8E=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增常驻调度器 daily/scheduler.py + run-scheduler.ps1(定时生成/推送) - 新增 daily/bridge_manager.py:Windows 兼容的 Cursor SDK 桥接 - 新增 skills/daily-featured-pick 首推 Skill 与叙事轴/去重逻辑 - 新闻抓取窗口、GitHub 搜索、企微 delta 模式等多项改进 - 补充设计文档与 superpowers 计划/规范 - 新增对应测试(scheduler、featured_pick、github_search、news_fetch_window 等) Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 14 +- README.md | 202 +-- daily/__main__.py | 10 +- daily/board_history.py | 124 +- daily/board_select.py | 20 +- daily/bridge_manager.py | 156 +++ daily/config.py | 4 +- daily/featured_pick.py | 11 +- daily/format_wecom.py | 224 +++- daily/generate.py | 65 +- daily/github/search.py | 67 +- daily/llm_client.py | 20 +- daily/news/__init__.py | 13 + daily/news/feeds.py | 2 +- daily/news/feeds_cn.py | 9 - daily/report_data.py | 2 +- daily/scheduler.py | 284 ++++ daily/skills_board.py | 79 +- daily/skills_group.py | 22 + daily/text_utils.py | 12 +- docs/design-wecom-delta-mode.md | 273 ++++ .../plans/2026-07-09-wecom-delta-mode.md | 1162 +++++++++++++++++ .../plans/2026-07-14-wecom-diversity-dedup.md | 604 +++++++++ ...2026-07-14-wecom-diversity-dedup-design.md | 271 ++++ run-scheduler.ps1 | 43 + skills/daily-featured-pick/SKILL.md | 87 ++ tests/test_ai_news_research.py | 117 ++ tests/test_board_history.py | 48 + tests/test_board_select.py | 33 + tests/test_featured_pick.py | 95 ++ tests/test_featured_resolve.py | 51 +- tests/test_github_search.py | 71 + tests/test_news_fetch_window.py | 156 +++ tests/test_scheduler.py | 118 ++ tests/test_wecom_delta.py | 279 +++- 35 files changed, 4389 insertions(+), 359 deletions(-) create mode 100644 daily/bridge_manager.py create mode 100644 daily/scheduler.py create mode 100644 docs/design-wecom-delta-mode.md create mode 100644 docs/superpowers/plans/2026-07-09-wecom-delta-mode.md create mode 100644 docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md create mode 100644 docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md create mode 100644 run-scheduler.ps1 create mode 100644 skills/daily-featured-pick/SKILL.md create mode 100644 tests/test_ai_news_research.py create mode 100644 tests/test_featured_pick.py create mode 100644 tests/test_github_search.py create mode 100644 tests/test_news_fetch_window.py create mode 100644 tests/test_scheduler.py diff --git a/.env.example b/.env.example index 06ad1a2..f235681 100644 --- a/.env.example +++ b/.env.example @@ -25,12 +25,12 @@ GITHUB_TRENDING_SINCE=daily # GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxx # GITHUB_API_ENRICH=1 -# 企微短版(各榜 Top N,默认 10) -DAILY_WECOM_TRENDING=10 -DAILY_WECOM_HOT=10 -DAILY_WECOM_GITHUB_TRENDING=10 -DAILY_WECOM_GITHUB_EMERGING=10 -DAILY_WECOM_GITHUB_TOPIC=10 +# 企微短版(各榜 Top N,默认 5;周内不重复见 DAILY_BOARD_DEDUP_DAYS) +DAILY_WECOM_TRENDING=5 +DAILY_WECOM_HOT=5 +DAILY_WECOM_GITHUB_TRENDING=5 +DAILY_WECOM_GITHUB_EMERGING=5 +DAILY_WECOM_GITHUB_TOPIC=5 DAILY_WECOM_AI_NEWS=10 # research 模式额外技术类时讯条数(叠加在 AI 时讯精选之上) DAILY_WECOM_AI_NEWS_TECH=5 @@ -105,7 +105,7 @@ DAILY_AI_NEWS_PER_FEED=3 DAILY_AI_NEWS_PER_CATEGORY=5 # 多样性 / 去重(见 docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md) -# DAILY_BOARD_DEDUP_DAYS=7 +DAILY_BOARD_DEDUP_DAYS=7 # DAILY_BOARD_POOL_SIZE=50 # DAILY_FEATURED_DEDUP_DAYS=30 # DAILY_THEME_BAN_DAYS=7 diff --git a/README.md b/README.md index 508f8cd..50f2d07 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # skills-hot-daily -Skills / GitHub 早报推送 + 企微对话机器人(同一仓库、两套企微接入)。 +Skills / GitHub 早报推送(企微 Webhook)。 ## 项目结构 @@ -8,7 +8,7 @@ Skills / GitHub 早报推送 + 企微对话机器人(同一仓库、两套企 skills-hot-daily/ ├── README.md ├── .env.example # 早报 webhook、GitHub 等 -├── requirements.txt # 早报 Python 依赖 +├── requirements.txt # Python 依赖 ├── run-daily.ps1 # 生成 + 推送一条龙 ├── send-wecom.ps1 # 仅推送 ├── daily/ # 早报 Python 包 @@ -32,24 +32,15 @@ skills-hot-daily/ ├── skills/daily-editor/ # 早报 Cursor 编辑 Skill │ └── SKILL.md ├── logs/ -├── .cache/ -└── bot/ # 企微 API 模式对话机器人(独立 venv) - ├── main.py - ├── skills_service.py - └── scenarios/ +└── .cache/ ``` -| 模块 | 配置文件 | 启动方式 | -|------|----------|----------| -| **早报推送** | 根目录 `.env`(`WECOM_WEBHOOK_KEY` 等) | `.\run-daily.ps1` | -| **对话 Bot** | `bot/.env`(`WECOM_BOT_ID` / `SECRET` 等) | `cd bot` → `python main.py` | - --- -## 一、早报推送 +## 早报推送 ```powershell -cd d:\LY\test\tech\skills-hot-daily +cd d:\LY\diy\daily-robots pip install -r requirements.txt copy .env.example .env .\run-daily.ps1 @@ -84,15 +75,14 @@ WECOM_WEBHOOK_KEY=your-key | 研究 / 论文 | arXiv cs.CL/AI/LG、HF Papers | | 社区讨论 | HN、Reddit r/LocalLLaMA / ClaudeAI / ML 等 | -环境变量:`DAILY_AI_NEWS=1` · `DAILY_CN_AI_NEWS=1` · `DAILY_AI_NEWS_HOURS=72` · `DAILY_WECOM_AI_NEWS=10` · `DAILY_WECOM_CN_AI_NEWS=8` +环境变量:`DAILY_AI_NEWS=1` · `DAILY_CN_AI_NEWS=1` · `DAILY_AI_NEWS_HOURS=24` · `DAILY_WECOM_AI_NEWS=10` · `DAILY_WECOM_CN_AI_NEWS=10` **国内 AI 时讯**(RSS,见 `daily/news/feeds_cn.py`): | 类别 | 覆盖 | |------|------| -| AI 专业媒体 | 量子位、InfoQ 中文 | +| AI 专业媒体 | 量子位 | | 综合科技 | 36氪、雷锋网、Google News 中文 | -| 开发者社区 | 掘金(标题 AI 关键词过滤) | ### 生成架构(Tier B · Cursor 编辑层) @@ -135,7 +125,9 @@ Python 抓取 → Step1 趋势分析 → Step2 叙事写稿 → Python 分条推 ```env DAILY_REPORT_MODE=agent -DAILY_CURSOR_CWD=d:\LY\diy\skills-hot-daily # 早报 LLM 工作目录(与 bot 的 CURSOR_CWD 独立) +CURSOR_API_KEY=cursor_... +CURSOR_MODEL=composer-2.5 +DAILY_CURSOR_CWD=d:\LY\diy\daily-robots ``` | 文件 | 说明 | @@ -146,169 +138,11 @@ DAILY_CURSOR_CWD=d:\LY\diy\skills-hot-daily # 早报 LLM 工作目录(与 bo - 完整版 `YYYY-MM-DD.md` 仍为数据表格归档;企微版由 Agent 直接写 Markdown - Agent 失败自动回退 `classic`,不影响 `run-daily.ps1` -定时推送:Windows 任务计划程序或 `/loop 1d` 执行 `run-daily.ps1`。 +定时推送: ---- - -## 二、可对话 Skills 助手(企业微信智能机器人) - -在企微里 @ 机器人即可: -- **快查**:`trending 10`、`hot 10`、`搜索 react`(本地 skills 数据,秒回) -- **截图预览**:`preview` / `截图`(基于 `.env` 的 `CURSOR_CWD` 启动前端并发图) -- **通用任务**:任意自然语言需求,由 **Cursor Agent** 执行并回传结果 - -### 1. 创建 API 模式机器人 - -1. [企业微信管理后台](https://work.weixin.qq.com/) → **安全与管理** → **管理工具** → **智能机器人** → **创建机器人** -2. 选择 **API 模式创建** → **使用长连接** -3. 记录 **Bot ID** 和 **Secret**(Secret 只显示一次,请立即保存) -4. 设置可见范围,将机器人 **添加到目标群** 或允许成员单聊 - -普通成员路径:工作台 → 智能机器人 → 手动创建 → API 模式 → 长连接 - -### 2. 启动本地服务 - -```powershell -cd d:\LY\test\tech\skills-hot-daily\bot -python -m venv .venv -.\.venv\Scripts\Activate.ps1 -pip install -r requirements.txt -playwright install chromium -copy .env.example .env -# 编辑 .env:WECOM_BOT_ID / WECOM_BOT_SECRET / CURSOR_API_KEY -python main.py -``` - -服务需 **常驻运行**(本机、服务器或 Docker)。长连接模式下机器人进程须在线才能收消息。 - -### 3. 路由模式(ROUTING_MODE) - -| 模式 | 行为 | -|------|------| -| `hybrid`(默认) | `trending`/`hot`/`搜索`/`详情` 走本地快查;其余 @ 消息交给 Cursor | -| `cursor` | 所有消息都交给 Cursor 执行 | -| `skills` | 仅本地 skills 快查(旧行为) | - -**Cursor 任务示例**(群里发送): - -``` -@test 总结 trending top10,并推荐 3 个适合前端团队的 skill -@test 对比 mattpocock/skills 和 obra/superpowers 各有哪些热门 skill -@test 帮我写一段 npx skills add 的安装说明 -``` - -Cursor 在本机 `CURSOR_CWD` 目录下运行,默认 `d:\LY\test\tech`。复杂任务可能需要 1–10 分钟,流式消息会显示「Cursor 正在执行任务…」。 - -### 4. 前端截图预览(API 模式发图) - -项目路径读取 `.env` 中的 **`CURSOR_CWD`**。机器人会: - -1. 在 `CURSOR_CWD` 检测 `package.json`,若有 `dev` 脚本则执行 `PREVIEW_DEV_COMMAND`(默认 `npm run dev`) -2. 等待 `PREVIEW_PORT`(默认 `5173`)就绪,或用 `PREVIEW_URL` 直接访问 -3. Playwright 打开页面并截图 -4. 通过 API 模式 **上传图片 + 回复 image 消息** 到群 - -| 命令 | 说明 | -|------|------| -| `preview` / `截图` / `预览` | 访问 `http://127.0.0.1:5173/` 并截图 | -| `preview /login` | 指定路径 | -| `preview / 3000` | 指定端口 | -| `preview http://127.0.0.1:8080/` | 指定完整 URL | - -**多步网页操作**(登录、点菜单、再截图)见下一节,不再写死在代码里。 - -`.env` 可选配置: - -```env -CURSOR_CWD=d:\LY\test\tech -PREVIEW_PORT=5173 -PREVIEW_URL=http://127.0.0.1:5173/ -PREVIEW_DEV_COMMAND=npm run dev -PREVIEW_STARTUP_TIMEOUT=120 -``` - -若 `CURSOR_CWD` 下暂无前端项目,可先手动启动 dev server,或设置 `PREVIEW_URL` 指向已运行地址。 - -### 4b. 网页操作(Playwright 步骤引擎) - -支持三种方式定义操作流程,**无需改 Python 代码**: - -**1. 自然语言(企微里直接说)** - -``` -@test 访问登录页,输入账号密码,点击登录后进入主页,点击智能体管理菜单然后截图 -``` - -账号密码从 `.env` 读取(`{{PREVIEW_LOGIN_USER}}` / `{{PREVIEW_LOGIN_PASSWORD}}`),勿在群里发密码。 - -**2. 场景文件 YAML** - -`bot/scenarios/xiaobao-agent-manage.yaml` 示例: - -```yaml -name: xiaobao-agent-manage -steps: - - goto: /login - - fill: - field: 账号 - value: "{{PREVIEW_LOGIN_USER}}" - - fill: - field: 密码 - value: "{{PREVIEW_LOGIN_PASSWORD}}" - - click: 登录 - - wait: - url: "**/app/**" - - click: 智能体管理 - - wait: 1500 - - screenshot -``` - -触发:`@test browser xiaobao-agent-manage` - -场景搜索路径:`bot/scenarios/`、`CURSOR_CWD/.browser-scenarios/`、环境变量 `BROWSER_SCENARIOS_DIR`。 - -**3. 消息内 DSL** - -``` -browser: - goto /login - fill 账号 {{PREVIEW_LOGIN_USER}} - fill 密码 {{PREVIEW_LOGIN_PASSWORD}} - click 登录 - click 智能体管理 - screenshot -``` - -**支持的步骤**:`goto` · `fill` · `click` · `wait` · `screenshot` · `press` - -`.env` 登录与场景配置: - -```env -PREVIEW_LOGIN_USER=test_account -PREVIEW_LOGIN_PASSWORD=your_password -# BROWSER_DEFAULT_SCENARIO=xiaobao-agent-manage -``` - -### 5. 支持的快查命令 - -| 命令 | 说明 | -|------|------| -| `trending 10` / `趋势 10` | 近期增长榜 Top N(默认 10,最大 30) | -| `hot 10` / `实时 10` | 实时热度榜 | -| `all 10` / `总榜 10` | 历史总安装榜 | -| `搜索 react` / `search tdd` | 关键词搜索 | -| `详情 find-skills` | 单个 skill 详情 + 安装命令 | -| `preview` / `截图` | 启动 CURSOR_CWD 前端并截图发群 | -| `帮助` | 命令列表 | - -自然语言(非显式快查命令)会交给 **Cursor** 处理,例如 `@test 查 trending 并写推荐` 。 - -### 6. 本地测试(无需企微凭证) - -```powershell -cd d:\LY\test\tech\skills-hot-daily\bot -python -c "from skills_service import handle_command; print(handle_command('trending 5'))" -``` +- **常驻调度(推荐)**:`python -m daily schedule` 或 `.\run-scheduler.ps1`(默认 08:50 生成、09:00 推送,见 `DAILY_SCHEDULE_*`) +- Windows 任务计划:`.\register-daily-task.ps1` +- Cursor:`/loop 1d`(时间会漂移,仅临时用) --- @@ -321,14 +155,6 @@ python -c "from skills_service import handle_command; print(handle_command('tren | **邮件 + 企业微信邮箱** | 已有 SMTP | 中 | | **PushPlus / Server酱** | 个人微信中转 | 低(第三方) | -### 应用消息 API(简要) - -适合「推送给某个人」而非群聊。需在 [企业微信管理后台](https://work.weixin.qq.com/) 创建自建应用,调用: - -`POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN` - -消息体支持 `text` / `markdown` / `news` 等。需先 `gettoken` 再发消息,并维护 access_token 缓存。 - --- ## 注意事项 diff --git a/daily/__main__.py b/daily/__main__.py index e827de3..33d3db6 100644 --- a/daily/__main__.py +++ b/daily/__main__.py @@ -5,6 +5,7 @@ from __future__ import annotations import sys from daily.generate import main as generate_main +from daily.scheduler import main as schedule_main from daily.webhook import main as push_main @@ -14,7 +15,14 @@ def main() -> int: return generate_main() if cmd in {"push", "send", "webhook"}: return push_main(sys.argv[2:]) - print(f"未知命令: {cmd}\n用法: python -m daily [generate|push] [report_path]", file=sys.stderr) + if cmd in {"schedule", "scheduler", "daemon"}: + return schedule_main() + print( + f"未知命令: {cmd}\n" + "用法: python -m daily [generate|push|schedule] [report_path]\n" + " python -m daily schedule [--once] [--dry-run]", + file=sys.stderr, + ) return 1 diff --git a/daily/board_history.py b/daily/board_history.py index f0d6e2f..6c6922b 100644 --- a/daily/board_history.py +++ b/daily/board_history.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import logging +import re from datetime import datetime, timedelta from pathlib import Path from typing import Any @@ -15,29 +16,119 @@ logger = logging.getLogger(__name__) BOARD_KEYS = RECENT_BOARD_KEYS +_GITHUB_REPO_RE = re.compile(r"github\.com/([\w.-]+/[\w.-]+)", re.I) +_SKILL_SH_RE = re.compile(r"skills\.sh/([\w.-]+/[\w.-]+(?:/[\w.-]+)?)", re.I) + +# 与企微正文榜单标题对齐;顺序用于切分相邻 section +_WECOM_SECTION_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ("skills_trending", re.compile(r"Skills\s+Trending", re.I)), + ("skills_hot", re.compile(r"Skills\s+Hot", re.I)), + ("github_trending", re.compile(r"GitHub\s+Trending", re.I)), + ("github_emerging", re.compile(r"GitHub\s+新兴", re.I)), + ("github_topic", re.compile(r"Topic\s+", re.I)), +) + def extract_shown_keys(board: str, items: list[dict[str, Any]]) -> list[str]: - """从最终展示 items 抽取稳定 identity key。""" + """从最终展示 items 抽取稳定 identity key。 + + Skills 榜同时写入 skill id 与 source,便于周去重按仓屏蔽。 + """ keys: list[str] = [] seen: set[str] = set() for item in items: if board.startswith("skills_"): - key = skill_id(item) + candidates = [skill_id(item), str(item.get("source") or "").strip()] else: - key = str(item.get("repo") or "") - if not key or key in seen: - continue - seen.add(key) - keys.append(key) + candidates = [str(item.get("repo") or "")] + for key in candidates: + if not key or key in seen: + continue + seen.add(key) + keys.append(key) return keys +def _keys_from_board_items(board: str, data: dict[str, Any]) -> set[str]: + if board == "github_topic": + topic = data.get("github_topic") or {} + items = topic.get("repos") if isinstance(topic, dict) else [] + else: + items = data.get(board) or [] + if not isinstance(items, list): + return set() + return set(extract_shown_keys(board, items)) + + +def parse_wecom_shown_keys(md: str) -> dict[str, set[str]]: + """从企微 Markdown 按榜单 section 解析已展示 keys(冷启动兼容)。""" + out: dict[str, set[str]] = {board: set() for board in BOARD_KEYS} + if not (md or "").strip(): + return out + + hits: list[tuple[int, str]] = [] + for board, pattern in _WECOM_SECTION_PATTERNS: + for match in pattern.finditer(md): + hits.append((match.start(), board)) + if not hits: + return out + hits.sort(key=lambda x: x[0]) + + for idx, (start, board) in enumerate(hits): + end = hits[idx + 1][0] if idx + 1 < len(hits) else len(md) + chunk = md[start:end] + if board.startswith("skills_"): + out[board].update(_SKILL_SH_RE.findall(chunk)) + else: + out[board].update(_GITHUB_REPO_RE.findall(chunk)) + return out + + +def _load_shown_keys_for_day(path: Path, date_str: str) -> dict[str, set[str]] | None: + """读一日历史:优先 wecom_shown_keys;缺省则回退 wecom.md,再回退 data 榜字段。""" + empty = {board: set() for board in BOARD_KEYS} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.warning("读取 wecom_shown_keys %s 失败:%s", path, exc) + return None + + data = payload.get("data") if isinstance(payload, dict) else None + if not isinstance(data, dict): + return empty + + out: dict[str, set[str]] = {board: set() for board in BOARD_KEYS} + shown = data.get("wecom_shown_keys") + if isinstance(shown, dict): + for board in BOARD_KEYS: + keys = shown.get(board) or [] + if isinstance(keys, list): + out[board].update(str(k) for k in keys if k) + if any(out.values()): + return out + + wecom_path = OUTPUT_DIR / f"{date_str}.wecom.md" + if wecom_path.exists(): + try: + md = wecom_path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning("读取 wecom.md 回退 %s 失败:%s", wecom_path, exc) + else: + parsed = parse_wecom_shown_keys(md) + if any(parsed.values()): + return parsed + + for board in BOARD_KEYS: + out[board].update(_keys_from_board_items(board, data)) + return out + + def load_recent_shown_keys( date_str: str, *, lookback_days: int | None = None, ) -> dict[str, set[str]]: - """近 N 日 data.wecom_shown_keys 并集(不含当日)。缺省或读失败视为空集。""" + """近 N 日已展示 keys 并集(不含当日)。缺省或读失败视为空集。""" empty = {board: set() for board in BOARD_KEYS} try: dt = datetime.strptime(date_str, "%Y-%m-%d") @@ -52,22 +143,11 @@ def load_recent_shown_keys( path = OUTPUT_DIR / f"{prev_date}.data.json" if not path.exists(): continue - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError) as exc: - logger.warning("读取 wecom_shown_keys %s 失败:%s", path, exc) - continue - data = payload.get("data") - if not isinstance(data, dict): - continue - shown = data.get("wecom_shown_keys") - if not isinstance(shown, dict): + day_keys = _load_shown_keys_for_day(path, prev_date) + if day_keys is None: continue for board in BOARD_KEYS: - keys = shown.get(board) or [] - if not isinstance(keys, list): - continue - out[board].update(str(k) for k in keys if k) + out[board].update(day_keys.get(board) or set()) return out diff --git a/daily/board_select.py b/daily/board_select.py index b0151bd..6c63153 100644 --- a/daily/board_select.py +++ b/daily/board_select.py @@ -24,20 +24,22 @@ def board_select( from daily.skills_group import group_skills_by_source pool = group_skills_by_source(items, limit=pool_size, pool_size=pool_size) - - def key_fn(x: dict[str, Any]) -> str: - return skill_id(x) else: pool = items[: max(pool_size, limit)] - def key_fn(x: dict[str, Any]) -> str: - return str(x.get("repo") or "") - out: list[dict[str, Any]] = [] for item in pool: - k = key_fn(item) - if not k or k in recent_keys: - continue + if kind == "skill": + key = skill_id(item) + source = str(item.get("source") or "").strip() + if (key and key in recent_keys) or (source and source in recent_keys): + continue + if not key and not source: + continue + else: + key = str(item.get("repo") or "") + if not key or key in recent_keys: + continue out.append(item) if len(out) >= limit: break diff --git a/daily/bridge_manager.py b/daily/bridge_manager.py new file mode 100644 index 0000000..f0c1f47 --- /dev/null +++ b/daily/bridge_manager.py @@ -0,0 +1,156 @@ +"""Windows 兼容的 Cursor SDK bridge 管理。""" + +from __future__ import annotations + +import codecs +import json +import logging +import os +import subprocess +import threading +import time +from pathlib import Path +from typing import Any, Mapping + +from daily.config import ROOT, env + +logger = logging.getLogger(__name__) + +READY_LINE_PREFIX = "cursor-sdk-bridge ready " +_bridge_lock = threading.Lock() +_bridge_process: subprocess.Popen[bytes] | None = None + + +def _cursor_cwd() -> str: + return env("DAILY_CURSOR_CWD") or env("CURSOR_CWD") or str(ROOT) + + +def _parse_discovery_line(line: str) -> Mapping[str, Any] | None: + if not line.startswith(READY_LINE_PREFIX): + return None + payload = line[len(READY_LINE_PREFIX) :].strip() + loaded = json.loads(payload) + if not isinstance(loaded, dict): + raise RuntimeError("Bridge discovery payload must be an object") + return loaded + + +def _read_discovery_polling(process: subprocess.Popen[bytes], timeout: float = 60) -> Mapping[str, Any]: + """不用 selectors,避免 Windows 上 WinError 10038。""" + if process.stderr is None: + raise RuntimeError("Bridge stderr unavailable") + + fd = process.stderr.fileno() + was_blocking = os.get_blocking(fd) + os.set_blocking(fd, False) + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + pending = "" + stderr_lines: list[str] = [] + deadline = time.monotonic() + timeout + + try: + while time.monotonic() < deadline: + try: + chunk = os.read(fd, 8192) + except BlockingIOError: + chunk = b"" + + if chunk: + pending += decoder.decode(chunk) + while "\n" in pending: + line, pending = pending.split("\n", 1) + stderr_lines.append(line) + discovery = _parse_discovery_line(line) + if discovery is not None: + return discovery + else: + code = process.poll() + if code is not None: + pending += decoder.decode(b"", final=True) + if pending.strip(): + stderr_lines.append(pending.strip()) + joined = "\n".join(stderr_lines)[-2000:] + raise RuntimeError( + f"Bridge 启动失败 exit={code}: {joined or '无 stderr 输出'}" + ) + time.sleep(0.05) + finally: + os.set_blocking(fd, was_blocking) + + raise RuntimeError("等待 Cursor bridge 就绪超时") + + +def _auth_token_from_discovery(discovery: Mapping[str, Any]) -> str: + token = str(discovery.get("authToken") or "").strip() + if token: + return token + token_file = discovery.get("authTokenFile") + if token_file: + return Path(str(token_file)).read_text(encoding="utf-8").strip() + raise RuntimeError("Bridge discovery 缺少 auth token") + + +def warm_cursor_bridge(force: bool = False) -> None: + """启动 cursor-sdk-bridge 并写入 CURSOR_SDK_BRIDGE_* 环境变量。""" + global _bridge_process + + with _bridge_lock: + if ( + not force + and _bridge_process is not None + and _bridge_process.poll() is None + and os.environ.get("CURSOR_SDK_BRIDGE_URL") + and os.environ.get("CURSOR_SDK_BRIDGE_TOKEN") + ): + return + + if _bridge_process is not None and _bridge_process.poll() is None: + _bridge_process.terminate() + try: + _bridge_process.wait(timeout=5) + except subprocess.TimeoutExpired: + _bridge_process.kill() + + from cursor_sdk._vendor import resolve_bridge_path + + cwd = _cursor_cwd() + argv = [resolve_bridge_path(), "--workspace", cwd] + logger.info("启动 Cursor bridge workspace=%s", cwd) + + process = subprocess.Popen( + argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + try: + discovery = _read_discovery_polling(process) + except Exception: + process.kill() + process.wait(timeout=5) + raise + + url = str(discovery.get("url") or "").strip() + if not url: + host = str(discovery.get("host") or "127.0.0.1") + port = discovery.get("port") + url = f"http://{host}:{port}" + + token = _auth_token_from_discovery(discovery) + os.environ["CURSOR_SDK_BRIDGE_URL"] = url + os.environ["CURSOR_SDK_BRIDGE_TOKEN"] = token + _bridge_process = process + logger.info("Cursor bridge 就绪: %s", url) + + +def shutdown_cursor_bridge() -> None: + global _bridge_process + with _bridge_lock: + if _bridge_process is None: + return + if _bridge_process.poll() is None: + _bridge_process.terminate() + try: + _bridge_process.wait(timeout=5) + except subprocess.TimeoutExpired: + _bridge_process.kill() + _bridge_process = None diff --git a/daily/config.py b/daily/config.py index 09de410..8aed4f6 100644 --- a/daily/config.py +++ b/daily/config.py @@ -149,8 +149,8 @@ def board_dedup_days() -> int: def board_pool_size() -> int: - fallback = env_int("DAILY_WECOM_SKILL_POOL", 50) - return max(1, env_int("DAILY_BOARD_POOL_SIZE", max(50, fallback))) + fallback = env_int("DAILY_WECOM_SKILL_POOL", 400) + return max(1, env_int("DAILY_BOARD_POOL_SIZE", max(200, fallback))) def featured_dedup_days() -> int: diff --git a/daily/featured_pick.py b/daily/featured_pick.py index cf73fcc..4082a7c 100644 --- a/daily/featured_pick.py +++ b/daily/featured_pick.py @@ -62,6 +62,10 @@ def load_recent_featured_keys(date_str: str, days: int | None = None) -> set[str if not isinstance(data, dict): continue key = str(data.get("featured_pick_key") or "").strip() + if not key: + featured = data.get("featured_pick") + if isinstance(featured, dict): + key = featured_identity_key(featured) if key: out.add(key) return out @@ -84,7 +88,12 @@ def load_yesterday_featured_key(date_str: str) -> str | None: if not isinstance(data, dict): return None key = str(data.get("featured_pick_key") or "").strip() - return key or None + if key: + return key + featured = data.get("featured_pick") + if isinstance(featured, dict): + return featured_identity_key(featured) or None + return None def _featured_rng(date_str: str) -> random.Random: diff --git a/daily/format_wecom.py b/daily/format_wecom.py index 3091e15..b6bcc7e 100644 --- a/daily/format_wecom.py +++ b/daily/format_wecom.py @@ -7,7 +7,7 @@ from typing import Any from daily.config import wecom_skill_desc_limit from daily.localize import LocalizeJob, localize_brief_descriptions, needs_chinese -from daily.skills_group import group_skills_by_source, skill_id as board_skill_id +from daily.skills_group import group_skills_by_source from daily.text_utils import trim_brief ICONS = { @@ -469,6 +469,32 @@ def _prepare_grouped_wecom_skills( return wecom_items[:limit], keys +def _normalize_skill_source_groups(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """将条目规范为按 source 合并的榜单项(展示始终为合并态)。""" + flat: list[dict[str, Any]] = [] + for item in items: + flat.extend(_flatten_skill_board_item(item)) + if not flat: + return [] + return group_skills_by_source(flat, limit=len(flat), pool_size=len(flat)) + + +def _skill_primary_id(item: dict[str, Any]) -> str: + return str(item.get("id") or f"{item.get('source')}/{item.get('title')}" or "").strip() + + +def _source_from_skill_key(key: str) -> str: + from daily.skills_group import source_from_skill_key + + return source_from_skill_key(key) + + +def expand_skill_recent_keys(keys: set[str] | None) -> set[str]: + from daily.skills_group import expand_skill_recent_keys as _expand + + return _expand(keys) + + def _merge_skill_board_items( moves: list[dict[str, Any]], full_items: list[dict[str, Any]], @@ -477,29 +503,80 @@ def _merge_skill_board_items( exclude_keys: set[str] | None = None, recent_keys: set[str] | None = None, ) -> tuple[list[dict[str, Any]], set[str]]: + """异动优先,不足时用深池补满;按 source 合并态取条。 + + 周去重按 source(含从 skill id 展开);同日避开其它榜时也按 source。 + """ from daily.delta import skill_id as move_skill_id exclude = exclude_keys or set() - recent = recent_keys or set() - seen: set[str] = set() - flat: list[dict[str, Any]] = [] + recent = expand_skill_recent_keys(recent_keys) + exclude_sources = {_source_from_skill_key(k) for k in exclude if k} + exclude_sources.update(k for k in exclude if k) + + def _blocked(item: dict[str, Any]) -> bool: + primary = _skill_primary_id(item) + source = str(item.get("source") or "").strip() + if primary and primary in recent: + return True + if source and source in recent: + return True + if primary and primary in exclude: + return True + if source and (source in exclude_sources or source in exclude): + return True + return False + + groups: list[dict[str, Any]] = [] + seen_sources: set[str] = set() + + move_rows: list[dict[str, Any]] = [] + seen_move_ids: set[str] = set() for move in moves: key = move_skill_id(move) - if not key or key in seen or key in exclude: + if not key or key in seen_move_ids: continue - seen.add(key) - flat.append(_move_to_skill_row(move)) - pad_pool = max(limit * 5, len(flat), 50) - for item in full_items: - if len(flat) >= pad_pool: + move_source = str(move.get("source") or "").strip() + if key in exclude or (move_source and move_source in exclude_sources): + continue + if key in recent or (move_source and move_source in recent): + continue + seen_move_ids.add(key) + move_rows.append(_move_to_skill_row(move)) + + for group in _normalize_skill_source_groups(move_rows): + source = str(group.get("source") or "?") + if source in seen_sources or _blocked(group): + continue + seen_sources.add(source) + groups.append(group) + if len(groups) >= limit: break - for row in _flatten_skill_board_item(item): - key = board_skill_id(row) - if not key or key in seen or key in exclude or key in recent: + + if len(groups) < limit: + for group in _normalize_skill_source_groups(full_items): + if len(groups) >= limit: + break + source = str(group.get("source") or "?") + if source in seen_sources or _blocked(group): continue - seen.add(key) - flat.append(row) - return _prepare_grouped_wecom_skills(flat, limit=limit) + seen_sources.add(source) + groups.append(group) + + if not groups: + return [], set() + prepared = finalize_wecom_skill_groups(groups[:limit]) + wecom_items = [_grouped_skill_to_wecom_item(x) for x in prepared] + # 供同日 Hot 排除:主键 + source + keys: set[str] = set() + for item in groups[:limit]: + primary = _skill_primary_id(item) + if primary: + keys.add(primary) + source = str(item.get("source") or "").strip() + if source: + keys.add(source) + return wecom_items[:limit], keys def build_skills_delta_sections( @@ -508,8 +585,8 @@ def build_skills_delta_sections( *, trending_full: list[dict[str, Any]] | None = None, hot_full: list[dict[str, Any]] | None = None, - trending_limit: int = 10, - hot_limit: int = 10, + trending_limit: int = 5, + hot_limit: int = 5, pad: bool = False, recent_trending: set[str] | None = None, recent_hot: set[str] | None = None, @@ -517,11 +594,14 @@ def build_skills_delta_sections( sections: list[str] = [] trending_keys: set[str] = set() if pad: + skill_recent = expand_skill_recent_keys( + (recent_trending or set()) | (recent_hot or set()) + ) t_items, trending_keys = _merge_skill_board_items( trending_moves, trending_full or [], trending_limit, - recent_keys=recent_trending, + recent_keys=skill_recent, ) if t_items: lines = [f"{ICONS['trending']} **Skills Trending Top {len(t_items)}**"] @@ -533,7 +613,7 @@ def build_skills_delta_sections( hot_full or [], hot_limit, exclude_keys=trending_keys, - recent_keys=recent_hot, + recent_keys=skill_recent, ) if h_items: lines = [f"{ICONS['hot']} **Skills Hot Top {len(h_items)}**"] @@ -585,7 +665,7 @@ def _merge_github_board_items( for move in moves: repo = _github_move_to_repo(move) key = str(repo.get("repo") or "") - if not key or key in seen: + if not key or key in seen or key in recent: continue seen.add(key) merged.append(repo) @@ -607,15 +687,20 @@ def build_github_delta_sections( github_trending: list[dict[str, Any]] | None = None, github_emerging: list[dict[str, Any]] | None = None, github_topic: list[dict[str, Any]] | None = None, - trending_limit: int = 10, - emerging_limit: int = 10, - topic_limit: int = 10, + trending_limit: int = 5, + emerging_limit: int = 5, + topic_limit: int = 5, pad: bool = False, recent_board_keys: dict[str, set[str]] | None = None, ) -> str: sections: list[str] = [] recent = recent_board_keys or {} if pad: + github_recent = ( + (recent.get("github_trending") or set()) + | (recent.get("github_emerging") or set()) + | (recent.get("github_topic") or set()) + ) mapping = [ ("github_trending_moves", "github_trending", github_trending or [], trending_limit, "github", "GitHub Trending", False), ("github_emerging_moves", "github_emerging", github_emerging or [], emerging_limit, "emerging", "GitHub 新兴", True), @@ -626,8 +711,9 @@ def build_github_delta_sections( movement.get(move_key) or [], full_repos, limit, - recent_repos=recent.get(board_key), + recent_repos=github_recent, ) + github_recent |= {str(r.get("repo") or "") for r in repos if r.get("repo")} if not repos: continue lines = [f"{ICONS[icon_key]} **{label} Top {len(repos)}**"] @@ -678,11 +764,11 @@ def resolve_wecom_board_items( github_trending: list[dict[str, Any]] | None = None, github_emerging: list[dict[str, Any]] | None = None, github_topic: list[dict[str, Any]] | None = None, - wecom_trending: int = 10, - wecom_hot: int = 10, - wecom_github: int = 10, - wecom_emerging: int = 10, - wecom_topic: int = 10, + wecom_trending: int = 5, + wecom_hot: int = 5, + wecom_github: int = 5, + wecom_emerging: int = 5, + wecom_topic: int = 5, pad: bool = False, date_str: str | None = None, trending_pad: list[dict[str, Any]] | None = None, @@ -704,8 +790,21 @@ def resolve_wecom_board_items( } recent_board_keys: dict[str, set[str]] = {} + skill_recent: set[str] = set() + github_recent: set[str] = set() if pad and date_str: recent_board_keys = load_recent_board_keys(date_str) + # Trending / Hot 共用周去重:任一类出现过的 source 两边都不再展示 + skill_recent = expand_skill_recent_keys( + (recent_board_keys.get("skills_trending") or set()) + | (recent_board_keys.get("skills_hot") or set()) + ) + # GitHub 三榜共用周去重:任一类出现过的 repo 各榜都不再展示 + github_recent = ( + (recent_board_keys.get("github_trending") or set()) + | (recent_board_keys.get("github_emerging") or set()) + | (recent_board_keys.get("github_topic") or set()) + ) t_moves, h_moves = partition_skill_moves_for_wecom( movement.get("skills_trending_moves") or [], @@ -716,36 +815,41 @@ def resolve_wecom_board_items( t_moves, trending_pad if trending_pad else trending, wecom_trending, - recent_keys=recent_board_keys.get("skills_trending"), + recent_keys=skill_recent, ) h_items, _ = _merge_skill_board_items( h_moves, hot_pad if hot_pad else hot, wecom_hot, exclude_keys=trending_keys, - recent_keys=recent_board_keys.get("skills_hot"), + recent_keys=skill_recent, + ) + gt_items = _merge_github_board_items( + movement.get("github_trending_moves") or [], + github_trending_pad if github_trending_pad else (github_trending or []), + wecom_github, + recent_repos=github_recent, + ) + github_recent |= {str(r.get("repo") or "") for r in gt_items if r.get("repo")} + ge_items = _merge_github_board_items( + movement.get("github_emerging_moves") or [], + github_emerging_pad if github_emerging_pad else (github_emerging or []), + wecom_emerging, + recent_repos=github_recent, + ) + github_recent |= {str(r.get("repo") or "") for r in ge_items if r.get("repo")} + gtopic_items = _merge_github_board_items( + movement.get("github_topic_moves") or [], + github_topic_pad if github_topic_pad else (github_topic or []), + wecom_topic, + recent_repos=github_recent, ) return { "skills_trending": t_items, "skills_hot": h_items, - "github_trending": _merge_github_board_items( - movement.get("github_trending_moves") or [], - github_trending_pad if github_trending_pad else (github_trending or []), - wecom_github, - recent_repos=recent_board_keys.get("github_trending"), - ), - "github_emerging": _merge_github_board_items( - movement.get("github_emerging_moves") or [], - github_emerging_pad if github_emerging_pad else (github_emerging or []), - wecom_emerging, - recent_repos=recent_board_keys.get("github_emerging"), - ), - "github_topic": _merge_github_board_items( - movement.get("github_topic_moves") or [], - github_topic_pad if github_topic_pad else (github_topic or []), - wecom_topic, - recent_repos=recent_board_keys.get("github_topic"), - ), + "github_trending": gt_items, + "github_emerging": ge_items, + "github_topic": gtopic_items, } t_flat = [_move_to_skill_row(m) for m in t_moves] @@ -778,11 +882,11 @@ def replace_wecom_board_sections( github_trending: list[dict[str, Any]] | None = None, github_emerging: list[dict[str, Any]] | None = None, github_topic: list[dict[str, Any]] | None = None, - wecom_trending: int = 10, - wecom_hot: int = 10, - wecom_github: int = 10, - wecom_emerging: int = 10, - wecom_topic: int = 10, + wecom_trending: int = 5, + wecom_hot: int = 5, + wecom_github: int = 5, + wecom_emerging: int = 5, + wecom_topic: int = 5, pad: bool = False, date_str: str | None = None, trending_pad: list[dict[str, Any]] | None = None, @@ -854,11 +958,11 @@ def replace_wecom_skill_sections( github_trending: list[dict[str, Any]] | None = None, github_emerging: list[dict[str, Any]] | None = None, github_topic: list[dict[str, Any]] | None = None, - wecom_trending: int = 10, - wecom_hot: int = 10, - wecom_github: int = 10, - wecom_emerging: int = 10, - wecom_topic: int = 10, + wecom_trending: int = 5, + wecom_hot: int = 5, + wecom_github: int = 5, + wecom_emerging: int = 5, + wecom_topic: int = 5, pad: bool = False, date_str: str | None = None, trending_pad: list[dict[str, Any]] | None = None, diff --git a/daily/generate.py b/daily/generate.py index aedbdf8..b39bf7d 100644 --- a/daily/generate.py +++ b/daily/generate.py @@ -510,23 +510,26 @@ def _format_skill_section(items: list[dict[str, Any]], *, hot: bool = False) -> def generate_report() -> tuple[str, str, Path, Path]: - trending_n = env_int("DAILY_TRENDING_LIMIT", 150) - hot_n = max(env_int("DAILY_HOT_LIMIT", 150), compare_depth()) + # Hot/Trending 前排同 source 极密,需更深抓取才能凑够展示用的唯一 source + trending_n = env_int("DAILY_TRENDING_LIMIT", 400) + hot_n = max(env_int("DAILY_HOT_LIMIT", 400), compare_depth()) compare_n = compare_depth() - skill_pool = max(10, env_int("DAILY_WECOM_SKILL_POOL", 200)) - wecom_trending = env_int("DAILY_WECOM_TRENDING", 10) - wecom_hot = env_int("DAILY_WECOM_HOT", 10) - pad_pool = wecom_pad_pool_size(max(wecom_trending, wecom_hot, 10)) + skill_pool = max(10, env_int("DAILY_WECOM_SKILL_POOL", 400)) + wecom_trending = env_int("DAILY_WECOM_TRENDING", 5) + wecom_hot = env_int("DAILY_WECOM_HOT", 5) + pad_pool = wecom_pad_pool_size(max(wecom_trending, wecom_hot, 5)) skill_pool = max(skill_pool, pad_pool) github_limit = env_int("DAILY_GITHUB_TRENDING_LIMIT", 10) - wecom_github = env_int("DAILY_WECOM_GITHUB_TRENDING", env_int("DAILY_WECOM_REPOS", 10)) - github_fetch_n = max(github_limit, compare_n, wecom_github, pad_pool) + wecom_github = env_int("DAILY_WECOM_GITHUB_TRENDING", env_int("DAILY_WECOM_REPOS", 5)) + # 周去重后顶刊 sticky,HTML/~30 条不够补满;深池默认 100(Search 已分页) + github_pool = max(pad_pool, env_int("DAILY_GITHUB_POOL", 100)) + github_fetch_n = max(github_limit, compare_n, wecom_github, github_pool) emerging_limit = env_int("DAILY_GITHUB_EMERGING_LIMIT", 10) - wecom_emerging = env_int("DAILY_WECOM_GITHUB_EMERGING", 10) - emerging_fetch_n = max(emerging_limit, compare_n, wecom_emerging, pad_pool) + wecom_emerging = env_int("DAILY_WECOM_GITHUB_EMERGING", 5) + emerging_fetch_n = max(emerging_limit, compare_n, wecom_emerging, github_pool) topic_limit = env_int("DAILY_GITHUB_TOPIC_LIMIT", 10) - wecom_topic = env_int("DAILY_WECOM_GITHUB_TOPIC", 10) - topic_fetch_n = max(topic_limit, compare_n, wecom_topic, pad_pool) + wecom_topic = env_int("DAILY_WECOM_GITHUB_TOPIC", 5) + topic_fetch_n = max(topic_limit, compare_n, wecom_topic, github_pool) feed = load_feed(force=True) prev_ids = _load_snapshot() @@ -576,10 +579,21 @@ def generate_report() -> tuple[str, str, Path, Path]: } pool = max(board_pool_size(), skill_pool, pad_pool) recent_shown = load_recent_shown_keys(date_str) + from daily.skills_group import expand_skill_recent_keys + + skill_recent = expand_skill_recent_keys( + recent_shown["skills_trending"] | recent_shown["skills_hot"] + ) + # GitHub 三榜共用周去重:任一类出现过的 repo 各榜都不再展示 + github_recent = ( + recent_shown["github_trending"] + | recent_shown["github_emerging"] + | recent_shown["github_topic"] + ) selected_trending = board_select( board="skills_trending", items=trending, - recent_keys=recent_shown["skills_trending"], + recent_keys=skill_recent, limit=wecom_trending, pool_size=pool, kind="skill", @@ -587,7 +601,7 @@ def generate_report() -> tuple[str, str, Path, Path]: selected_hot = board_select( board="skills_hot", items=hot, - recent_keys=recent_shown["skills_hot"], + recent_keys=skill_recent, limit=wecom_hot, pool_size=pool, kind="skill", @@ -595,23 +609,25 @@ def generate_report() -> tuple[str, str, Path, Path]: selected_github = board_select( board="github_trending", items=github_trending, - recent_keys=recent_shown["github_trending"], + recent_keys=github_recent, limit=wecom_github, pool_size=pool, kind="github", ) + github_recent |= {str(r.get("repo") or "") for r in selected_github if r.get("repo")} selected_emerging = board_select( board="github_emerging", items=github_emerging, - recent_keys=recent_shown["github_emerging"], + recent_keys=github_recent, limit=wecom_emerging, pool_size=pool, kind="github", ) + github_recent |= {str(r.get("repo") or "") for r in selected_emerging if r.get("repo")} selected_topic = board_select( board="github_topic", items=github_topic, - recent_keys=recent_shown["github_topic"], + recent_keys=github_recent, limit=wecom_topic, pool_size=pool, kind="github", @@ -842,7 +858,7 @@ def generate_report() -> tuple[str, str, Path, Path]: gt_pad = board_select( board="skills_trending", items=trending, - recent_keys=recent_shown["skills_trending"], + recent_keys=skill_recent, limit=pad_pool, pool_size=pool, kind="skill", @@ -850,7 +866,7 @@ def generate_report() -> tuple[str, str, Path, Path]: gh_pad = board_select( board="skills_hot", items=hot, - recent_keys=recent_shown["skills_hot"], + recent_keys=skill_recent, limit=pad_pool, pool_size=pool, kind="skill", @@ -858,12 +874,17 @@ def generate_report() -> tuple[str, str, Path, Path]: wecom_github_items = [_prepare_github_item(item) for item in selected_github] wecom_emerging_items = [_prepare_github_item(item) for item in selected_emerging] wecom_topic_items = [_prepare_github_item(item) for item in selected_topic] + github_pad_recent = ( + recent_shown["github_trending"] + | recent_shown["github_emerging"] + | recent_shown["github_topic"] + ) wecom_github_pad = [ _prepare_github_item(item) for item in board_select( board="github_trending", items=github_trending, - recent_keys=recent_shown["github_trending"], + recent_keys=github_pad_recent, limit=pad_pool, pool_size=pool, kind="github", @@ -874,7 +895,7 @@ def generate_report() -> tuple[str, str, Path, Path]: for item in board_select( board="github_emerging", items=github_emerging, - recent_keys=recent_shown["github_emerging"], + recent_keys=github_pad_recent, limit=pad_pool, pool_size=pool, kind="github", @@ -885,7 +906,7 @@ def generate_report() -> tuple[str, str, Path, Path]: for item in board_select( board="github_topic", items=github_topic, - recent_keys=recent_shown["github_topic"], + recent_keys=github_pad_recent, limit=pad_pool, pool_size=pool, kind="github", diff --git a/daily/github/search.py b/daily/github/search.py index a90a2a1..003165d 100644 --- a/daily/github/search.py +++ b/daily/github/search.py @@ -43,38 +43,59 @@ def search_github_repos( logger.warning("GitHub Search 需要 GITHUB_TOKEN: %s", query[:80]) return [] + target = max(1, min(int(limit), 1000)) + per_page = min(100, target) + repos: list[dict[str, Any]] = [] + seen: set[str] = set() + page = 1 + try: with httpx.Client( timeout=20.0, verify=certifi.where(), headers=github_api_headers(), ) as client: - resp = client.get( - "https://api.github.com/search/repositories", - params={ - "q": query, - "sort": sort, - "order": "desc", - "per_page": min(max(limit, 1), 30), - }, - ) - if resp.status_code != 200: - logger.warning("GitHub Search 失败 (%s): %s", resp.status_code, query[:80]) - return [] - items = resp.json().get("items") or [] + while len(repos) < target: + resp = client.get( + "https://api.github.com/search/repositories", + params={ + "q": query, + "sort": sort, + "order": "desc", + "per_page": per_page, + "page": page, + }, + ) + if resp.status_code != 200: + logger.warning( + "GitHub Search 失败 (%s page=%s): %s", + resp.status_code, + page, + query[:80], + ) + break + items = resp.json().get("items") or [] + if not items: + break + for item in items: + full_name = item.get("full_name") or "" + if not full_name or full_name in seen: + continue + seen.add(full_name) + repos.append(_repo_from_api_item(item, source="api-search")) + if len(repos) >= target: + break + if len(items) < per_page: + break + page += 1 + # Search API 最多约 1000 条 / 10 页 + if page > 10: + break except Exception as exc: logger.warning("GitHub Search 异常: %s", exc) - return [] + return repos[:target] - repos: list[dict[str, Any]] = [] - for item in items: - full_name = item.get("full_name") or "" - if not full_name: - continue - repos.append(_repo_from_api_item(item, source="api-search")) - if len(repos) >= limit: - break - return repos + return repos[:target] def _date_days_ago(days: int) -> str: diff --git a/daily/llm_client.py b/daily/llm_client.py index b6712e4..eb3e709 100644 --- a/daily/llm_client.py +++ b/daily/llm_client.py @@ -76,16 +76,9 @@ def _cursor_chat(system: str, user: str) -> str: return "" from cursor_sdk import Agent, AgentOptions, CursorAgentError, LocalAgentOptions - from daily.config import ensure_bot_on_path - - ensure_bot_on_path() - try: - from bridge_manager import warm_cursor_bridge - except ImportError: - warm_cursor_bridge = lambda: None # noqa: E731 + from daily.bridge_manager import warm_cursor_bridge cwd = env("DAILY_CURSOR_CWD") or str(ROOT) - # bridge_manager 读 bot env_config 的 CURSOR_CWD,早报侧须先对齐工作目录 os.environ["CURSOR_CWD"] = cwd warm_cursor_bridge() model = env("CURSOR_MODEL") or "composer-2.5" @@ -115,5 +108,16 @@ def llm_chat(system: str, user: str) -> str: return "" +def has_cursor_configured() -> bool: + return bool((env("CURSOR_API_KEY") or "").strip()) + + +def cursor_agent_prompt(system: str, user: str) -> str: + """仅 Cursor SDK Agent(可用 WebSearch 等工具),不走 OpenAI 兼容 API。""" + if not has_cursor_configured(): + return "" + return _cursor_chat(system, user) + + def has_llm_configured() -> bool: return bool(env("DAILY_LLM_API_KEY") or env("OPENAI_API_KEY") or env("CURSOR_API_KEY")) diff --git a/daily/news/__init__.py b/daily/news/__init__.py index 4e54ad4..33799eb 100644 --- a/daily/news/__init__.py +++ b/daily/news/__init__.py @@ -1,13 +1,26 @@ from daily.news.fetch import ( + fetch_ai_news, + fetch_cn_ai_news, + format_cn_news_section, + format_news_section, + ) + + __all__ = [ + "fetch_ai_news", + "fetch_cn_ai_news", + "format_news_section", + "format_cn_news_section", + ] + diff --git a/daily/news/feeds.py b/daily/news/feeds.py index 05676c2..be1bcb7 100644 --- a/daily/news/feeds.py +++ b/daily/news/feeds.py @@ -27,7 +27,7 @@ NEWS_CATEGORIES: tuple[NewsCategory, ...] = ( name="厂商官方", icon="🏢", feeds=( - NewsFeed("Anthropic Claude 更新", "https://docs.anthropic.com/en/release-notes/feed"), + NewsFeed("Anthropic Claude 更新", "https://platform.claude.com/docs/en/release-notes/overview"), NewsFeed("OpenAI", "https://openai.com/news/rss.xml"), NewsFeed("Google AI", "https://blog.google/technology/ai/rss/"), NewsFeed("DeepMind", "https://deepmind.google/blog/rss.xml"), diff --git a/daily/news/feeds_cn.py b/daily/news/feeds_cn.py index 1b37e21..271507b 100644 --- a/daily/news/feeds_cn.py +++ b/daily/news/feeds_cn.py @@ -44,7 +44,6 @@ CN_NEWS_CATEGORIES: tuple[NewsCategory, ...] = ( icon="📰", feeds=( NewsFeed("量子位", "https://www.qbitai.com/feed"), - NewsFeed("InfoQ 中文", "https://www.infoq.cn/feed/AI"), ), ), NewsCategory( @@ -60,12 +59,4 @@ CN_NEWS_CATEGORIES: tuple[NewsCategory, ...] = ( ), ), ), - NewsCategory( - id="dev", - name="开发者社区", - icon="💻", - feeds=( - NewsFeed("掘金", "https://juejin.cn/rss", ai_filter=True), - ), - ), ) diff --git a/daily/report_data.py b/daily/report_data.py index f787bce..de26eac 100644 --- a/daily/report_data.py +++ b/daily/report_data.py @@ -94,7 +94,7 @@ def _slim_news_items( def _wecom_skill_pool() -> int: - return max(10, env_int("DAILY_WECOM_SKILL_POOL", 200)) + return max(10, env_int("DAILY_WECOM_SKILL_POOL", 400)) def build_llm_input( diff --git a/daily/scheduler.py b/daily/scheduler.py new file mode 100644 index 0000000..dd27866 --- /dev/null +++ b/daily/scheduler.py @@ -0,0 +1,284 @@ +"""常驻调度:按配置时刻生成早报并推送企微。""" + +from __future__ import annotations + +import json +import logging +import subprocess +import sys +import time +from dataclasses import dataclass +from datetime import date, datetime, time as dt_time, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +from daily.config import ( + CACHE_DIR, + LOG_DIR, + OUTPUT_DIR, + ROOT, + schedule_generate_at, + schedule_push_at, + schedule_timezone_name, +) + +logger = logging.getLogger(__name__) + +_STATE_FILE = CACHE_DIR / "scheduler-state.json" +_POLL_SECONDS = 15 + + +@dataclass(frozen=True) +class ClockTime: + hour: int + minute: int + + +@dataclass +class SchedulerState: + last_generate_date: str | None = None + last_push_date: str | None = None + + @classmethod + def load(cls) -> SchedulerState: + if not _STATE_FILE.exists(): + return cls() + try: + raw = json.loads(_STATE_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + return cls() + if not isinstance(raw, dict): + return cls() + return cls( + last_generate_date=raw.get("last_generate_date"), + last_push_date=raw.get("last_push_date"), + ) + + def save(self) -> None: + _STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + payload = { + "last_generate_date": self.last_generate_date, + "last_push_date": self.last_push_date, + } + _STATE_FILE.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def parse_hhmm(value: str) -> ClockTime: + raw = (value or "").strip() + parts = raw.split(":", 1) + if len(parts) != 2: + raise ValueError(f"无效时间格式: {value!r},应为 HH:MM") + hour = int(parts[0]) + minute = int(parts[1]) + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError(f"无效时间: {value!r}") + return ClockTime(hour=hour, minute=minute) + + +def load_timezone() -> ZoneInfo: + name = schedule_timezone_name() + try: + return ZoneInfo(name) + except Exception as exc: + raise RuntimeError(f"无效时区 DAILY_SCHEDULE_TZ={name!r}") from exc + + +def _localize(day: date, clock: ClockTime, tz: ZoneInfo) -> datetime: + return datetime.combine(day, dt_time(clock.hour, clock.minute), tz) + + +def next_occurrence_after(clock: ClockTime, tz: ZoneInfo, after: datetime) -> datetime: + local = after.astimezone(tz) + candidate = local.replace(hour=clock.hour, minute=clock.minute, second=0, microsecond=0) + if candidate <= local: + candidate += timedelta(days=1) + return candidate + + +def _today_slot(day: date, clock: ClockTime, tz: ZoneInfo) -> datetime: + return _localize(day, clock, tz) + + +def _run_daily_subcommand(subcmd: str, *extra: str) -> int: + cmd = [sys.executable, "-m", "daily", subcmd, *extra] + logger.info("执行: %s", " ".join(cmd)) + proc = subprocess.run(cmd, cwd=str(ROOT), check=False) + return int(proc.returncode) + + +def run_generate() -> int: + return _run_daily_subcommand("generate") + + +def run_push_for_date(date_str: str) -> int: + report = OUTPUT_DIR / f"{date_str}.wecom.md" + if not report.exists(): + logger.error("推送失败:报告不存在 %s", report) + return 1 + return _run_daily_subcommand("push", str(report)) + + +def _setup_logging() -> Path: + LOG_DIR.mkdir(parents=True, exist_ok=True) + log_path = LOG_DIR / "scheduler.log" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[ + logging.FileHandler(log_path, encoding="utf-8"), + logging.StreamHandler(sys.stdout), + ], + ) + return log_path + + +def _sleep_until(target: datetime, tz: ZoneInfo) -> None: + while True: + now = datetime.now(tz) + seconds = (target - now).total_seconds() + if seconds <= 0: + return + time.sleep(min(seconds, _POLL_SECONDS)) + + +def plan_next_action( + *, + now: datetime, + tz: ZoneInfo, + state: SchedulerState, + generate_at: ClockTime, + push_at: ClockTime, +) -> tuple[datetime, str] | None: + """返回下一次应执行的动作;若今日已全部完成则返回明日 generate。""" + today = now.astimezone(tz).date() + today_str = today.isoformat() + gen_done = state.last_generate_date == today_str + push_done = state.last_push_date == today_str + gen_slot = _today_slot(today, generate_at, tz) + push_slot = _today_slot(today, push_at, tz) + + # 推送窗口内:generate 未做则立即补跑(须先于 push) + if not gen_done and gen_slot <= now <= push_slot: + return now, "generate" + # 已过推送时刻:仅当 generate 已完成时补跑 push + if not push_done and gen_done and now >= push_slot: + return now, "push" + + candidates: list[tuple[datetime, str]] = [] + if not gen_done and gen_slot > now: + candidates.append((gen_slot, "generate")) + if not push_done and push_slot > now: + candidates.append((push_slot, "push")) + if candidates: + return min(candidates, key=lambda item: item[0]) + + tomorrow_gen = next_occurrence_after(generate_at, tz, now) + return tomorrow_gen, "generate" + + +def run_scheduled_action(action: str, *, today_str: str) -> int: + if action == "generate": + return run_generate() + if action == "push": + return run_push_for_date(today_str) + raise ValueError(f"未知动作: {action}") + + +def tick_once( + *, + now: datetime | None = None, + tz: ZoneInfo | None = None, + state: SchedulerState | None = None, + generate_at: ClockTime | None = None, + push_at: ClockTime | None = None, + dry_run: bool = False, +) -> SchedulerState: + tz = tz or load_timezone() + now = now or datetime.now(tz) + state = state or SchedulerState.load() + generate_at = generate_at or parse_hhmm(schedule_generate_at()) + push_at = push_at or parse_hhmm(schedule_push_at()) + + today_str = now.astimezone(tz).date().isoformat() + planned = plan_next_action( + now=now, + tz=tz, + state=state, + generate_at=generate_at, + push_at=push_at, + ) + if not planned: + return state + + run_at, action = planned + if run_at > now: + if not dry_run: + logger.info("下次 %s @ %s (%s)", action, run_at.isoformat(), tz.key) + _sleep_until(run_at, tz) + elif not dry_run: + slot = _today_slot(now.astimezone(tz).date(), generate_at if action == "generate" else push_at, tz) + logger.info( + "补跑 %s(计划 %02d:%02d,当前 %s)", + action, + slot.hour, + slot.minute, + now.astimezone(tz).strftime("%H:%M"), + ) + + if dry_run: + logger.info("[dry-run] 将执行 %s @ %s", action, run_at.isoformat()) + return state + + logger.info("开始 %s(%s)", action, today_str) + code = run_scheduled_action(action, today_str=today_str) + if code != 0: + logger.error("%s 失败,exit=%s", action, code) + else: + if action == "generate": + state.last_generate_date = today_str + elif action == "push": + state.last_push_date = today_str + state.save() + logger.info("%s 完成", action) + return state + + +def main() -> int: + dry_run = "--dry-run" in sys.argv[1:] + once = "--once" in sys.argv[1:] + + log_path = _setup_logging() + tz = load_timezone() + generate_at = parse_hhmm(schedule_generate_at()) + push_at = parse_hhmm(schedule_push_at()) + + logger.info( + "调度器启动 tz=%s generate=%02d:%02d push=%02d:%02d log=%s", + tz.key, + generate_at.hour, + generate_at.minute, + push_at.hour, + push_at.minute, + log_path, + ) + + state = SchedulerState.load() + try: + while True: + state = tick_once( + tz=tz, + state=state, + generate_at=generate_at, + push_at=push_at, + dry_run=dry_run, + ) + if once or dry_run: + break + except KeyboardInterrupt: + logger.info("调度器已停止(KeyboardInterrupt)") + return 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/daily/skills_board.py b/daily/skills_board.py index 71041fc..4df07e6 100644 --- a/daily/skills_board.py +++ b/daily/skills_board.py @@ -2,14 +2,16 @@ from __future__ import annotations +import json import logging import re +import time from typing import Any, Literal import certifi import httpx -from daily.config import env +from daily.config import CACHE_DIR, env logger = logging.getLogger(__name__) @@ -17,6 +19,81 @@ Board = Literal["trending", "hot"] SKILLS_SITE = "https://www.skills.sh" USER_AGENT = "Mozilla/5.0 (compatible; skills-hot-daily/1.0; +https://skills.sh)" +FEED_URLS = [ + "https://cdn.jsdelivr.net/gh/NeverSight/skills.sh_feed@main/data/feed.json", + "https://raw.githubusercontent.com/NeverSight/skills.sh_feed/main/data/feed.json", +] +FEED_CACHE_TTL = 600 +FEED_CACHE_FILE = CACHE_DIR / "feed.json" + +_feed_cache: dict[str, Any] = {"data": None, "fetched_at": 0.0} + + +def format_installs(n: int | float) -> str: + if n >= 1_000_000: + return f"{n / 1_000_000:.1f}M" + if n >= 1_000: + return f"{n / 1_000:.1f}K" + return str(int(n)) + + +def _fetch_feed_json(url: str) -> dict[str, Any]: + headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} + with httpx.Client( + timeout=httpx.Timeout(20.0, connect=10.0), + verify=certifi.where(), + follow_redirects=True, + ) as client: + resp = client.get(url, headers=headers) + resp.raise_for_status() + return resp.json() + + +def _load_feed_disk_cache() -> dict[str, Any] | None: + if not FEED_CACHE_FILE.exists(): + return None + try: + return json.loads(FEED_CACHE_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("读取 feed 本地缓存失败: %s", exc) + return None + + +def _save_feed_disk_cache(data: dict[str, Any]) -> None: + FEED_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + FEED_CACHE_FILE.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + +def load_feed(force: bool = False) -> dict[str, Any]: + now = time.time() + if not force and _feed_cache["data"] and now - _feed_cache["fetched_at"] < FEED_CACHE_TTL: + return _feed_cache["data"] + + errors: list[str] = [] + for url in FEED_URLS: + for attempt in range(3): + try: + data = _fetch_feed_json(url) + _feed_cache["data"] = data + _feed_cache["fetched_at"] = now + _save_feed_disk_cache(data) + logger.info("skills feed 已更新: %s", url) + return data + except Exception as exc: + msg = f"{url} (#{attempt + 1}): {exc}" + errors.append(msg) + logger.debug("拉取失败 %s", msg) + time.sleep(0.5 * (attempt + 1)) + + stale = _load_feed_disk_cache() + if stale: + logger.warning("网络不可用,回退到 feed 本地缓存") + _feed_cache["data"] = stale + _feed_cache["fetched_at"] = now + return stale + + raise RuntimeError(f"无法获取 skills 数据。最近错误: {errors[-1] if errors else 'unknown'}") + _SKILL_RE = re.compile( r'\{"source":"(?P[^"]+)","skillId":"(?P[^"]+)",' r'"name":"(?P[^"]+)","installs":(?P\d+)' diff --git a/daily/skills_group.py b/daily/skills_group.py index 54c33b5..42021c3 100644 --- a/daily/skills_group.py +++ b/daily/skills_group.py @@ -9,6 +9,28 @@ def skill_id(item: dict[str, Any]) -> str: return str(item.get("id") or f"{item.get('source')}/{item.get('title')}") +def source_from_skill_key(key: str) -> str: + """从 skill id(source/title…)还原 source:去掉最后一段 title。""" + parts = [p for p in str(key or "").split("/") if p] + if len(parts) >= 2: + return "/".join(parts[:-1]) + return str(key or "").strip() + + +def expand_skill_recent_keys(keys: set[str] | None) -> set[str]: + """周去重 blocklist:保留原始 key,并展开为 source。""" + out: set[str] = set() + for key in keys or set(): + k = str(key or "").strip() + if not k: + continue + out.add(k) + src = source_from_skill_key(k) + if src: + out.add(src) + return out + + def format_installs(n: int | float) -> str: if n >= 1_000_000: return f"{n / 1_000_000:.1f}M" diff --git a/daily/text_utils.py b/daily/text_utils.py index 8bdf881..a7cdde0 100644 --- a/daily/text_utils.py +++ b/daily/text_utils.py @@ -15,7 +15,7 @@ def clip_text(text: str, limit: int) -> str: def trim_brief(text: str, limit: int) -> str: - """企微简要:控制在 limit 内,优先在句读处截断,不加省略号。""" + """企微简要:控制在 limit 内,优先在句读/词边界截断,不加省略号。""" text = _WS.sub(" ", (text or "").strip()) if not text or limit <= 0 or len(text) <= limit: return text @@ -27,4 +27,12 @@ def trim_brief(text: str, limit: int) -> str: pos = text.find(sep) if pos != -1 and pos + 1 <= limit: return text[: pos + 1] - return text[:limit].rstrip(",、;: ") + for sep in (". ", "! ", "? ", "; "): + pos = text.rfind(sep, 0, limit + 1) + if pos != -1 and pos + 1 >= min(limit // 2, 20): + return text[: pos + 1].rstrip() + if len(text) > limit: + space = text.rfind(" ", 0, limit + 1) + if space >= min(limit // 2, 20): + return text[:space].rstrip(",、;: ,.;") + return text[:limit].rstrip(",、;: ,.;") diff --git a/docs/design-wecom-delta-mode.md b/docs/design-wecom-delta-mode.md new file mode 100644 index 0000000..d130acd --- /dev/null +++ b/docs/design-wecom-delta-mode.md @@ -0,0 +1,273 @@ +# Design: 企微早报 Delta 模式 + +Generated: 2026-07-09 +Repo: daily-robots +Status: DRAFT +Mode: Builder + +## Problem Statement + +企微早报每天推送五榜 Top 10 + 18 条新闻,内容与前几日高度重复(`find-skills`、`openclaw`、飞书集群等长期霸榜)。读者真实需求是「今天有什么新变化」,而非「再读一遍黄页」。 + +根因: + +1. `daily-agent/SKILL.md` 要求即使较昨日无新增,仍须完整列出 Top 榜。 +2. `movement` 仅用于 opening / signals,列表区块仍全量渲染。 +3. Trending 与 Hot 独立展示,同一 skill 描述写两遍。 +4. 新闻 `DAILY_AI_NEWS_HOURS=72`,无已推送 link 去重,旧闻可连续出现。 + +## What Makes This Cool + +把早报从「日报复印机」变成「变化通知」:只有新入榜、新新闻、编辑推荐时才占版面;榜全稳且无新新闻时静默不推。读者打开企微即知「今天值得扫一眼的是什么」。 + +## Explicit Non-Goals(已否决方案) + +以下方案**不在本设计范围内**: + +| 方案 | 状态 | +|------|------| +| 静态页 / 外链档案库 | ❌ 不做 | +| 今日一装(每天一个 `npx skills add`) | ❌ 不做 | +| 按星期轮换版面 | ❌ 不做 | +| 榜首锚点(稳定日仍展示 #1) | ❌ 不做 | + +## Premises + +1. 重复感主要来自**列表区块全量复印**,而非 opening 里引用榜首数字。 +2. `output/*.data.json` 与 `daily/delta.py` 已具备新入榜对比能力,应上升为**列表渲染主数据源**。 +3. 企微消息仍在应用内读完,不依赖外部页面。 +4. 叙事层(opening、信号、首推、新闻)保持充实;缩短的是**榜单列表**,不是整报。 + +## Recommended Approach: Delta 模式 + +### 环境变量 + +```env +# full = 现有行为(全量 Top 榜列表) +# delta = 本设计(默认推荐) +DAILY_WECOM_MODE=delta + +# 无对比基准时(首日或缺历史 data.json)是否自动 full 一次 +DAILY_DELTA_BASELINE_FALLBACK=full # full | empty + +# 推送闸门全不满足时是否跳过 webhook(仍写 output 文件) +DAILY_SKIP_PUSH_WHEN_SILENT=1 + +# 强制推送(忽略静默) +# DAILY_FORCE_PUSH=1 + +# 新闻:缩短窗口 + 去重天数 +DAILY_AI_NEWS_HOURS=24 +DAILY_NEWS_DEDUP_DAYS=7 +``` + +### 推送闸门(Push Gate) + +满足**任一**条件则生成并推送企微早报: + +| 条件 | 数据源 | +|------|--------| +| 任榜单有新入条目 | `movement.*_moves` 非空 | +| 去重后仍有新新闻 | 国际或国内 AI 时讯 | +| 存在 `featured_pick` | Step 0 编辑推荐 | +| `DAILY_FORCE_PUSH=1` | 环境变量 | + +**静默日**:以上皆不满足 → 不调用 webhook(`DAILY_SKIP_PUSH_WHEN_SILENT=1` 时)。 +仍执行 `daily generate`,写入 `output/{date}.md`、`output/{date}.wecom.md`、`output/{date}.data.json` 留档。 + +**注意**:仅新闻有新、榜单全稳时**仍推送**,但 Skills/GitHub 列表区块整块省略(不是全天静默)。 + +### 列表渲染(Delta 列表) + +`DAILY_WECOM_MODE=delta` 时: + +#### Skills + +- **仅展示** `movement.skills_trending_moves` / `movement.skills_hot_moves` 中的新入榜条目。 +- **跨榜去重**:按 `skill_id`(`id` 或 `source/title`)合并;同一 skill 只出现一次,标注来源榜(如 `Trending #4 · Hot #2`)。 +- **无新入**:该榜区块**整块不出现**(不写多行「较昨日无新增」)。 + +#### GitHub + +- 仅展示 `movement.github_trending_moves`、`github_emerging_moves`、`github_topic_moves`。 +- 无新入则区块省略。 + +#### 不包含 + +- 全量 Top N 列表 +- 榜首锚点 +- `(新入 … #n)` 括号标注(与现 `agent_workflow._strip_new_entry_notes` 一致,列表标题用 `[新入 #n]` 前缀即可) + +### 固定骨架(不因 Delta 缩短) + +Agent 模式(`DAILY_REPORT_MODE=agent`)下,以下区块**保持**: + +- opening(2–3 句,首句含具体证据) +- headline / 今日主题 +- 今日信号(3–5 条) +- 今日首推 +- 国际 AI / 国内 AI 精选(条数仍由 `DAILY_WECOM_AI_NEWS` 等控制) + +榜单变短;叙事与新闻不主动砍到 0。 + +### Full 模式逃生口 + +`DAILY_WECOM_MODE=full` 时行为与**当前生产一致**(`format_wecom.build_wecom_report` / `replace_wecom_skill_sections` 全量 Top N)。用于手动切回或对比测试。 + +### 首日 / 无历史基准 + +`find_previous_data(date)` 返回 `None` 时: + +| `DAILY_DELTA_BASELINE_FALLBACK` | 行为 | +|----------------------------------|------| +| `full`(推荐) | 当日按 full 模式渲染列表一次;次日起 delta | +| `empty` | 当日列表区块为空;opening 须说明「首日报,暂无对比基准」 | + +实现时在 `generate_report` 或 `build_llm_input` 传入 `baseline_date` 供 Agent 引用。 + +## News Dedup + +### P0(本阶段) + +- 维护 `cache/pushed-news-links.json`(或写入 `output/` 旁 cache):最近 `DAILY_NEWS_DEDUP_DAYS` 天已出现在企微早报中的 `link` 集合。 +- `prepare_wecom_news_items` / `prepare_wecom_cn_news_items` 输出前过滤已见 link。 +- `DAILY_AI_NEWS_HOURS` 默认改为 `24`(`.env.example` 同步)。 + +### P1(可选后续) + +- 标题归一化去重(同一事件多源报道) +- 每 `source_name` 每日上限 N 条 + +## Agent Skill 变更 + +文件:`skills/daily-agent/SKILL.md` + +### 删除 / 修改 + +- 删除规则:「即使某榜较昨日无新增,仍须完整列出 Top 榜条目」。 +- 删除:「禁止改用 movement 作为列表来源」(在 delta 模式下反转)。 + +### 新增 + +当 `DAILY_WECOM_MODE=delta`(或 llm_input 含 `wecom_mode: delta`): + +1. Agent **不写** Skills Trending / Hot / GitHub 列表(仍由 Python 插入,与现流程一致)。 +2. opening / signals **可引用**榜首与 movement 摘要;禁止在 signals 重复列表已展示的同一事实。 +3. 榜全稳时,signals 聚焦新闻与首推,不必编造榜单变化。 + +当 `wecom_mode: full` 时保持现有 SKILL 规则。 + +## Python 模块变更 + +| 模块 | 变更 | +|------|------| +| `daily/config.py` | `wecom_mode()`, `news_dedup_days()`, `skip_push_when_silent()`, `delta_baseline_fallback()` | +| `daily/delta.py` | 可选:`merge_skill_moves_for_wecom(trending_moves, hot_moves)` 跨榜去重 | +| `daily/format_wecom.py` | `build_skills_delta_section()`, `build_github_delta_section()`;`replace_wecom_skill_sections` 支持 delta | +| `daily/news/fetch.py` | `filter_pushed_news()` + cache 读写 | +| `daily/generate.py` | 推送闸门;baseline fallback;静默 skip push | +| `daily/report_data.py` | `llm_input` 增加 `wecom_mode`, `push_gate` 摘要 | +| `daily/agent_workflow.py` | 无逻辑变更;依赖 Python 插入 delta 列表 | +| `.env.example` | 新 env 文档 | + +## 企微消息示例 + +### 有变化日 + +```markdown +📰 **早报 · 2026-07-10** + +[opening:今天最大变化,含数字/条目名] + +🎯 **{headline}** + +💡 **今日信号** +> ... + +📦 **今日首推** +[...] + +🌍 **国际 AI · 精选 N** +... + +📈 **Skills Trending 变化** +1. [新入 #4] [**xxx**](...) · ... + 描述一行 + +🔥 **Skills Hot 变化** +1. [新入 #2] [**yyy**](...) · ... + +🐙 **GitHub Trending 变化** +1. [新入 #4] [owner/repo](...) · ... +``` + +### 仅新闻有新(榜稳) + +- 无 📈/🔥/🐙 区块 +- opening 可一句:「榜单较昨日 Top15 无新入;以下为今日 AI 时讯。」 + +### 静默日 + +- 不推送企微 +- `output/` 仍落盘;日志:`[silent] no push gate matched for 2026-07-10` + +## Approaches Considered + +### Approach A: 配置瘦身(缩 Top N、24h 新闻) + +- Effort: S | Risk: Low +- 只减篇幅,榜头仍天天重复;未解决根因。 + +### Approach B: Delta 列表 + 推送闸门 + 新闻去重(本设计) + +- Effort: M | Risk: Med +- 复用 `delta.py`;改 format + skill + push 逻辑。 + +### Approach C: 仅改 Agent 文案 + +- Effort: S | Risk: High +- Python 仍插入全量列表,规则冲突,不可持续。 + +**Recommendation: B** — 数据层与展示层一致,静默日与跨榜去重可测。 + +## Success Criteria + +1. 连续 3 天对比 `output/*.data.json`:企微列表区块**重复 skill_id 占比**显著下降。 +2. 榜全稳且无新新闻日:`DAILY_SKIP_PUSH_WHEN_SILENT=1` 时不发 webhook。 +3. Trending/Hot 同一 skill 在列表中**最多出现 1 次**。 +4. `DAILY_WECOM_MODE=full` 与现网行为一致(回归用)。 +5. 首日 `DAILY_DELTA_BASELINE_FALLBACK=full` 不产生空列表投诉。 + +## Open Questions + +1. 静默日是否需要在企微发一行「今日无更新」?当前设计:**不发**。 +2. 新闻去重 cache 是否纳入 git?建议:**否**,放 `cache/`(已在 `.gitignore`)。 +3. Classic 模式(非 agent)是否同步 delta?建议:**是**,同一 `format_wecom` 路径。 + +## Implementation Tasks + +| ID | Priority | Task | Files | +|----|----------|------|-------| +| T1 | P1 | 新增 config helpers + `.env.example` | `daily/config.py`, `.env.example` | +| T2 | P1 | 新闻 link 去重 cache | `daily/news/fetch.py`, `daily/config.py` | +| T3 | P1 | Delta 列表渲染 + 跨榜去重 | `daily/format_wecom.py`, `daily/delta.py` | +| T4 | P1 | 推送闸门 + 静默 skip push | `daily/generate.py`, `daily/webhook.py` | +| T5 | P1 | baseline fallback full 一次 | `daily/generate.py` | +| T6 | P1 | 更新 `daily-agent/SKILL.md` | `skills/daily-agent/SKILL.md` | +| T7 | P2 | `llm_input` 传 `wecom_mode` / push 摘要 | `daily/report_data.py` | +| T8 | P2 | 单元测试:跨榜去重、推送闸门、新闻去重 | `tests/test_wecom_delta.py` | + +## Test Plan + +- [ ] 有 `2026-07-09.data.json` 时生成 `2026-07-10`:列表仅含新入项 +- [ ] 人造「全稳 + 无新新闻」:不 push +- [ ] 人造「全稳 + 有新新闻」:push,无 Skills/GitHub 块 +- [ ] `DAILY_WECOM_MODE=full` 输出与改前 `2026-07-09.wecom.md` 结构一致 +- [ ] 无 baseline + `DAILY_DELTA_BASELINE_FALLBACK=full`:首日全量列表 +- [ ] 同一 skill 在 Trending/Hot moves 均出现:列表只 1 条 + +## What I Noticed + +- 重复感是**产品形态**问题,不是 Agent 文笔问题。 +- 明确否决静态页、今日一装、轮换、锚点后,方案边界清晰,实现可分期。 +- 推送闸门必须**把新闻算进去**,否则静默日会被新闻绕过。 diff --git a/docs/superpowers/plans/2026-07-09-wecom-delta-mode.md b/docs/superpowers/plans/2026-07-09-wecom-delta-mode.md new file mode 100644 index 0000000..df9f158 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-wecom-delta-mode.md @@ -0,0 +1,1162 @@ +# 企微早报 Delta 模式 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将企微早报从全量 Top 榜复印改为 Delta 变化通知:列表仅展示新入榜、新闻 7 天 link 去重、满足推送闸门才 webhook,榜全稳且无新新闻时静默。 + +**Architecture:** 在现有 `daily/delta.py` movement 数据之上,新增 `effective_wecom_mode`(含首日 baseline fallback)、`format_wecom` delta 渲染、`daily/news/pushed_links.py` 新闻去重缓存、`daily/push_gate.py` 推送闸门。`generate.py` 串联后把闸门结果写入 `output/{date}.data.json` 的 `meta.push_gate`;`webhook.py` 与 `run-daily.ps1` 推送前读取并跳过静默日。Agent 模式仍由 Python 插入榜单区块,更新 `skills/daily-agent/SKILL.md` 与 delta 对齐。 + +**Tech Stack:** Python 3.13+、stdlib `unittest` + `pytest`(仓库现有 `tests/test_featured_pick.py` 用 unittest)、`python-dotenv`、`httpx` + +## Global Constraints + +- `DAILY_WECOM_MODE` 合法值:`full` | `delta`;默认 `delta` +- `DAILY_DELTA_BASELINE_FALLBACK` 合法值:`full` | `empty`;默认 `full` +- `DAILY_SKIP_PUSH_WHEN_SILENT` 默认 `1`(真) +- `DAILY_FORCE_PUSH=1` 时忽略静默,强制推送 +- `DAILY_NEWS_DEDUP_DAYS` 默认 `7` +- `DAILY_AI_NEWS_HOURS` 默认改为 `24`(`.env.example`) +- 新闻去重 cache 路径:`CACHE_DIR / "pushed-news-links.json"`(即 `.cache/pushed-news-links.json`,已在 `.gitignore`) +- 静默日:仍写 `output/{date}.md`、`output/{date}.wecom.md`、`output/{date}.data.json`,仅跳过 webhook +- 跨榜去重:同一 `skill_id` 在 Trending/Hot moves 均出现时,只在 Trending 区块展示一条,`badge` 为 `Trending #4 · Hot #2` +- 不做:静态页、今日一装、轮换版面、榜首锚点 +- Classic 与 Agent 模式共用 `format_wecom` delta 路径 + +--- + +## File Structure + +| 文件 | 职责 | +|------|------| +| `daily/config.py` | `env_bool`、`wecom_mode()`、`news_dedup_days()`、`skip_push_when_silent()`、`delta_baseline_fallback()`、`force_push()` | +| `daily/news/pushed_links.py` | **新建** — 已推送新闻 link 缓存读写与过滤 | +| `daily/delta.py` | `effective_wecom_mode()`、`partition_skill_moves_for_wecom()` 跨榜去重 | +| `daily/push_gate.py` | **新建** — `PushGateResult`、`evaluate_push_gate()` | +| `daily/format_wecom.py` | delta 列表渲染、`replace_wecom_board_sections()` 替换 Skills+GitHub | +| `daily/generate.py` | 串联 effective mode、新闻去重、push gate、写 meta | +| `daily/webhook.py` | 推送前读 `meta.push_gate` | +| `daily/report_data.py` | `llm_input` 增加 `wecom_mode`、`effective_wecom_mode`、`push_gate` | +| `skills/daily-agent/SKILL.md` | delta 模式 Agent 规则 | +| `.env.example` | 新 env 文档 | +| `run-daily.ps1` | 静默日跳过 push | +| `tests/test_wecom_delta.py` | **新建** — 单元测试 | + +--- + +### Task 1: Config helpers + +**Files:** +- Modify: `daily/config.py`(文件末尾追加) +- Test: `tests/test_wecom_delta.py` + +**Interfaces:** +- Consumes: 无 +- Produces: + - `env_bool(key: str, default: bool) -> bool` + - `wecom_mode() -> Literal["full", "delta"]` + - `news_dedup_days() -> int` + - `skip_push_when_silent() -> bool` + - `delta_baseline_fallback() -> Literal["full", "empty"]` + - `force_push() -> bool` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_wecom_delta.py +"""Tests for WeCom delta mode.""" + +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from daily.config import ( + delta_baseline_fallback, + env_bool, + force_push, + news_dedup_days, + skip_push_when_silent, + wecom_mode, +) + + +class ConfigHelpersTests(unittest.TestCase): + def test_wecom_mode_defaults_delta(self): + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(wecom_mode(), "delta") + + def test_wecom_mode_full(self): + with patch.dict(os.environ, {"DAILY_WECOM_MODE": "full"}, clear=True): + self.assertEqual(wecom_mode(), "full") + + def test_env_bool_truthy(self): + with patch.dict(os.environ, {"DAILY_FORCE_PUSH": "1"}, clear=True): + self.assertTrue(env_bool("DAILY_FORCE_PUSH", False)) + + def test_skip_push_when_silent_default(self): + with patch.dict(os.environ, {}, clear=True): + self.assertTrue(skip_push_when_silent()) + + def test_news_dedup_days_default(self): + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(news_dedup_days(), 7) + + def test_delta_baseline_fallback_default(self): + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(delta_baseline_fallback(), "full") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_wecom_delta.py::ConfigHelpersTests -v` +Expected: FAIL with `ImportError` or `cannot import name 'wecom_mode'` + +- [ ] **Step 3: Write minimal implementation** + +在 `daily/config.py` 末尾追加: + +```python +def env_bool(key: str, default: bool) -> bool: + raw = env(key) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def wecom_mode() -> str: + raw = (env("DAILY_WECOM_MODE") or "delta").strip().lower() + return raw if raw in {"full", "delta"} else "delta" + + +def news_dedup_days() -> int: + return max(1, env_int("DAILY_NEWS_DEDUP_DAYS", 7)) + + +def skip_push_when_silent() -> bool: + return env_bool("DAILY_SKIP_PUSH_WHEN_SILENT", True) + + +def delta_baseline_fallback() -> str: + raw = (env("DAILY_DELTA_BASELINE_FALLBACK") or "full").strip().lower() + return raw if raw in {"full", "empty"} else "full" + + +def force_push() -> bool: + return env_bool("DAILY_FORCE_PUSH", False) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_wecom_delta.py::ConfigHelpersTests -v` +Expected: PASS(6 passed) + +- [ ] **Step 5: Commit** + +```bash +git add daily/config.py tests/test_wecom_delta.py +git commit -m "feat: 新增企微 Delta 模式相关配置读取函数" +``` + +--- + +### Task 2: News pushed-link dedup cache + +**Files:** +- Create: `daily/news/pushed_links.py` +- Modify: `daily/news/fetch.py`(`prepare_wecom_news_items` / `prepare_wecom_cn_news_items` 末尾调用过滤) +- Test: `tests/test_wecom_delta.py` + +**Interfaces:** +- Consumes: `daily.config.CACHE_DIR`, `daily.config.news_dedup_days()`, `daily.news.fetch._normalize_link` +- Produces: + - `load_pushed_links() -> dict[str, list[str]]` # `{"dates": {"2026-07-08": ["https://..."]}}` + - `filter_unpushed_items(items: list[dict], *, date_str: str) -> list[dict]` + - `record_pushed_links(date_str: str, links: list[str]) -> None` + +- [ ] **Step 1: Write the failing test** + +在 `tests/test_wecom_delta.py` 追加: + +```python +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +from daily.news.pushed_links import filter_unpushed_items, record_pushed_links + + +class NewsPushedLinksTests(unittest.TestCase): + def test_filter_and_record_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) / "pushed-news-links.json" + items = [ + {"title": "A", "link": "https://example.com/a?utm_source=x"}, + {"title": "B", "link": "https://example.com/b"}, + ] + with patch("daily.news.pushed_links._cache_path", return_value=cache): + with patch("daily.news.pushed_links.news_dedup_days", return_value=7): + record_pushed_links("2026-07-08", ["https://example.com/a"]) + out = filter_unpushed_items(items, date_str="2026-07-09") + self.assertEqual(len(out), 1) + self.assertEqual(out[0]["link"], "https://example.com/b") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_wecom_delta.py::NewsPushedLinksTests -v` +Expected: FAIL `ModuleNotFoundError: daily.news.pushed_links` + +- [ ] **Step 3: Write minimal implementation** + +创建 `daily/news/pushed_links.py`: + +```python +"""已推送企微早报的新闻 link 去重缓存。""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from daily.config import CACHE_DIR, news_dedup_days +from daily.news.fetch import _normalize_link + + +def _cache_path() -> Path: + return CACHE_DIR / "pushed-news-links.json" + + +def _load_raw() -> dict[str, Any]: + path = _cache_path() + if not path.exists(): + return {"dates": {}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {"dates": {}} + if not isinstance(data.get("dates"), dict): + return {"dates": {}} + return data + + +def _save_raw(data: dict[str, Any]) -> None: + path = _cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _prune(data: dict[str, Any], *, keep_days: int) -> None: + dates: dict[str, list[str]] = data.setdefault("dates", {}) + try: + anchor = max(datetime.strptime(d, "%Y-%m-%d") for d in dates) + except ValueError: + return + cutoff = anchor - timedelta(days=keep_days) + for key in list(dates.keys()): + try: + if datetime.strptime(key, "%Y-%m-%d") < cutoff: + dates.pop(key, None) + except ValueError: + dates.pop(key, None) + + +def load_pushed_link_set() -> set[str]: + data = _load_raw() + out: set[str] = set() + for links in (data.get("dates") or {}).values(): + if isinstance(links, list): + out.update(str(x) for x in links if x) + return out + + +def filter_unpushed_items( + items: list[dict[str, Any]], + *, + date_str: str, +) -> list[dict[str, Any]]: + seen = load_pushed_link_set() + out: list[dict[str, Any]] = [] + for item in items: + link = _normalize_link(str(item.get("link") or "")) + if not link or link in seen: + continue + out.append(item) + return out + + +def record_pushed_links(date_str: str, links: list[str]) -> None: + data = _load_raw() + dates: dict[str, list[str]] = data.setdefault("dates", {}) + normalized = [] + for link in links: + clean = _normalize_link(link) + if clean: + normalized.append(clean) + dates[date_str] = sorted(set(normalized)) + _prune(data, keep_days=news_dedup_days()) + _save_raw(data) +``` + +修改 `daily/news/fetch.py` — 在 `prepare_wecom_news_items` 返回前: + +```python +from daily.news.pushed_links import filter_unpushed_items + +def prepare_wecom_news_items(news: dict[str, Any], *, date_str: str | None = None) -> list[dict[str, Any]]: + # ... 现有逻辑不变,得到 items ... + if date_str: + items = filter_unpushed_items(items, date_str=date_str) + return items +``` + +对 `prepare_wecom_cn_news_items` 做同样修改(增加可选 `date_str` 参数)。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_wecom_delta.py::NewsPushedLinksTests -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add daily/news/pushed_links.py daily/news/fetch.py tests/test_wecom_delta.py +git commit -m "feat: 新增企微已推送新闻 link 去重缓存" +``` + +--- + +### Task 3: Cross-board skill move partition + +**Files:** +- Modify: `daily/delta.py`(文件末尾追加) +- Test: `tests/test_wecom_delta.py` + +**Interfaces:** +- Consumes: `daily.delta.skill_id` +- Produces: + - `effective_wecom_mode(*, date_str: str, configured_mode: str | None = None) -> str` + - `partition_skill_moves_for_wecom(trending_moves: list[dict], hot_moves: list[dict]) -> tuple[list[dict], list[dict]]` + +- [ ] **Step 1: Write the failing test** + +```python +from daily.delta import effective_wecom_mode, partition_skill_moves_for_wecom, skill_id + + +class SkillMovePartitionTests(unittest.TestCase): + def test_partition_dedupes_across_boards(self): + trending = [{"id": "a/b/foo", "rank": 4, "title": "foo", "source": "a/b", "installs": 1, "link": "", "description": ""}] + hot = [{"id": "a/b/foo", "rank": 2, "title": "foo", "source": "a/b", "installs": 1, "link": "", "description": ""}] + t_out, h_out = partition_skill_moves_for_wecom(trending, hot) + self.assertEqual(len(t_out), 1) + self.assertEqual(len(h_out), 0) + self.assertIn("Trending #4", t_out[0]["badge"]) + self.assertIn("Hot #2", t_out[0]["badge"]) + + def test_effective_mode_fallback_full_without_baseline(self): + with patch("daily.delta.find_previous_data", return_value=None): + with patch("daily.delta.wecom_mode", return_value="delta"): + with patch("daily.delta.delta_baseline_fallback", return_value="full"): + self.assertEqual(effective_wecom_mode(date_str="2026-07-10"), "full") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_wecom_delta.py::SkillMovePartitionTests -v` +Expected: FAIL `cannot import name 'partition_skill_moves_for_wecom'` + +- [ ] **Step 3: Write minimal implementation** + +在 `daily/delta.py` 顶部增加 import: + +```python +from daily.config import delta_baseline_fallback, wecom_mode +``` + +在文件末尾追加: + +```python +def effective_wecom_mode(*, date_str: str, configured_mode: str | None = None) -> str: + mode = configured_mode or wecom_mode() + if mode != "delta": + return "full" + if find_previous_data(date_str) is None and delta_baseline_fallback() == "full": + return "full" + return "delta" + + +def partition_skill_moves_for_wecom( + trending_moves: list[dict[str, Any]], + hot_moves: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + hot_by_id = {skill_id(m): m for m in hot_moves if skill_id(m)} + trending_out: list[dict[str, Any]] = [] + consumed_hot: set[str] = set() + for move in trending_moves: + sid = skill_id(move) + copy = dict(move) + badges = [f"Trending #{move.get('rank', '?')}"] + hot_match = hot_by_id.get(sid) + if hot_match: + badges.append(f"Hot #{hot_match.get('rank', '?')}") + consumed_hot.add(sid) + copy["badge"] = " · ".join(badges) + trending_out.append(copy) + hot_out: list[dict[str, Any]] = [] + for move in hot_moves: + sid = skill_id(move) + if sid in consumed_hot: + continue + copy = dict(move) + copy["badge"] = f"Hot #{move.get('rank', '?')}" + hot_out.append(copy) + return trending_out, hot_out +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_wecom_delta.py::SkillMovePartitionTests -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add daily/delta.py tests/test_wecom_delta.py +git commit -m "feat: 实现 Skills 跨榜去重与首日 baseline 模式判定" +``` + +--- + +### Task 4: Delta WeCom formatting + +**Files:** +- Modify: `daily/format_wecom.py` +- Test: `tests/test_wecom_delta.py` + +**Interfaces:** +- Consumes: `partition_skill_moves_for_wecom`, `finalize_wecom_skill_groups`, `_skill_line`, `_github_repo_lines` +- Produces: + - `move_to_wecom_skill_item(move: dict) -> dict` + - `build_skills_delta_sections(trending_moves: list, hot_moves: list) -> str` + - `build_github_delta_sections(movement: dict, *, topic_name: str) -> str` + - `replace_wecom_board_sections(md, *, mode, movement, trending_full, hot_full, github_full...) -> str` + +- [ ] **Step 1: Write the failing test** + +```python +from daily.format_wecom import build_github_delta_sections, build_skills_delta_sections + + +class DeltaFormatTests(unittest.TestCase): + def test_skills_delta_omits_empty_board(self): + moves = [{"id": "x/y/z", "rank": 3, "title": "z", "source": "x/y", "installs": 10, + "installs_fmt": "10", "link": "https://skills.sh/x/y/z", "description": "d", "badge": "Trending #3"}] + text = build_skills_delta_sections(moves, []) + self.assertIn("Skills Trending 变化", text) + self.assertIn("[新入 #3]", text) + self.assertNotIn("Skills Hot 变化", text) + + def test_github_delta_omits_stable_board(self): + movement = { + "github_trending_moves": [{"repo": "a/b", "url": "https://github.com/a/b", "rank": 1, "language": "Go"}], + "github_emerging_moves": [], + "github_topic_moves": [], + } + text = build_github_delta_sections(movement, topic_name="llm") + self.assertIn("GitHub Trending 变化", text) + self.assertNotIn("新兴", text) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_wecom_delta.py::DeltaFormatTests -v` +Expected: FAIL `cannot import name 'build_skills_delta_sections'` + +- [ ] **Step 3: Write minimal implementation** + +在 `daily/format_wecom.py` 追加(放在 `replace_wecom_skill_sections` 之前): + +```python +def _move_to_wecom_skill_item(move: dict[str, Any]) -> dict[str, Any]: + installs = int(move.get("installs") or 0) + return { + "title": move.get("title", "?"), + "source": move.get("source", "?"), + "installs_fmt": move.get("installs_fmt") or str(installs), + "link": move.get("link", ""), + "desc_short": (move.get("description") or "").strip(), + "badge": f"[新入 #{move.get('rank', '?')}] {move.get('badge', '')}".strip(), + } + + +def build_skills_delta_sections( + trending_moves: list[dict[str, Any]], + hot_moves: list[dict[str, Any]], +) -> str: + sections: list[str] = [] + if trending_moves: + prepared = finalize_wecom_skill_groups([_move_to_wecom_skill_item(m) for m in trending_moves]) + items = [_grouped_skill_to_wecom_item(x) for x in prepared] + lines = [f"{ICONS['trending']} **Skills Trending 变化**"] + for rank, item in enumerate(items, 1): + badge = item.pop("badge", "") if isinstance(item, dict) else "" + lines.extend(_skill_line(rank, item, badge=badge)) + sections.append("\n".join(lines)) + if hot_moves: + prepared = finalize_wecom_skill_groups([_move_to_wecom_skill_item(m) for m in hot_moves]) + items = [_grouped_skill_to_wecom_item(x) for x in prepared] + lines = [f"{ICONS['hot']} **Skills Hot 变化**"] + for rank, item in enumerate(items, 1): + badge = item.get("badge", "") + lines.extend(_skill_line(rank, item, badge=badge)) + sections.append("\n".join(lines)) + return "\n\n".join(sections) + + +def _github_move_to_repo(move: dict[str, Any]) -> dict[str, Any]: + return { + "repo": move.get("repo", "?"), + "url": move.get("url", ""), + "language": move.get("language", ""), + "stars_today_fmt": move.get("stars_today_fmt", ""), + "total_stars_fmt": move.get("total_stars_fmt", ""), + "created_at": move.get("created_at", ""), + "description": move.get("description", ""), + "desc_short": (move.get("description") or "").strip(), + "badge": f"[新入 #{move.get('rank', '?')}]", + } + + +def build_github_delta_sections(movement: dict[str, Any], *, topic_name: str) -> str: + sections: list[str] = [] + mapping = [ + ("github_trending_moves", "github", "GitHub Trending 变化", False), + ("github_emerging_moves", "emerging", "GitHub 新兴 变化", True), + ("github_topic_moves", "topic", f"Topic `{topic_name}` 变化", False), + ] + for key, icon_key, label, show_created in mapping: + moves = movement.get(key) or [] + if not moves: + continue + repos = [_github_move_to_repo(m) for m in moves] + lines = [f"{ICONS[icon_key]} **{label}**"] + for i, repo in enumerate(repos, 1): + badge = repo.pop("badge", "") + base = _github_repo_lines([repo], show_created=show_created) + if base and badge: + base[0] = f"{i}. {badge} " + base[0].split(". ", 1)[-1] + lines.extend(base) + sections.append("\n".join(lines)) + return "\n\n".join(sections) + + +_GITHUB_SECTIONS = re.compile( + r"🐙 \*\*GitHub Trending.*", + re.DOTALL, +) + + +def replace_wecom_board_sections( + md: str, + *, + mode: str, + movement: dict[str, Any], + trending: list[dict[str, Any]], + hot: list[dict[str, Any]], + topic_name: str, +) -> str: + from daily.delta import partition_skill_moves_for_wecom + + if mode == "delta": + t_moves, h_moves = partition_skill_moves_for_wecom( + movement.get("skills_trending_moves") or [], + movement.get("skills_hot_moves") or [], + ) + skills_sec = build_skills_delta_sections(t_moves, h_moves) + github_sec = build_github_delta_sections(movement, topic_name=topic_name) + board_block = "\n\n".join(x for x in [skills_sec, github_sec] if x) + if _SKILL_SECTIONS.search(md): + md = _SKILL_SECTIONS.sub("", md) + if _GITHUB_SECTIONS.search(md): + md = _GITHUB_SECTIONS.sub("", md) + marker = md.rstrip() + if board_block: + return marker + "\n\n" + board_block + "\n" + return marker + "\n" + trending_sec = build_skills_board_section("trending", "Skills Trending", trending) + hot_sec = build_skills_board_section("hot", "Skills Hot", hot) + replacement = f"{trending_sec}\n\n{hot_sec}\n\n" + if _SKILL_SECTIONS.search(md): + md = _SKILL_SECTIONS.sub(replacement, md) + elif "🐙 **GitHub Trending" in md: + idx = md.find("🐙 **GitHub Trending") + md = md[:idx] + replacement + md[idx:] + else: + md = md.rstrip() + "\n\n" + replacement + return md +``` + +保留原 `replace_wecom_skill_sections` 为薄包装,内部调用 `replace_wecom_board_sections(..., mode="full")` 以保持向后兼容。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_wecom_delta.py::DeltaFormatTests -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add daily/format_wecom.py tests/test_wecom_delta.py +git commit -m "feat: 新增企微 Delta 榜单区块渲染与替换逻辑" +``` + +--- + +### Task 5: Push gate module + +**Files:** +- Create: `daily/push_gate.py` +- Test: `tests/test_wecom_delta.py` + +**Interfaces:** +- Consumes: `daily.config.force_push`, `daily.config.skip_push_when_silent` +- Produces: + - `@dataclass PushGateResult: should_push: bool; reasons: list[str]; silent: bool` + - `evaluate_push_gate(*, movement, ai_news_items, cn_ai_news_items, featured_pick) -> PushGateResult` + +- [ ] **Step 1: Write the failing test** + +```python +from daily.push_gate import evaluate_push_gate + + +MOVEMENT_WITH_SKILL = { + "skills_trending_moves": [{"id": "a/b/c"}], + "skills_hot_moves": [], + "github_trending_moves": [], + "github_emerging_moves": [], + "github_topic_moves": [], +} + + +class PushGateTests(unittest.TestCase): + def test_push_when_board_has_moves(self): + gate = evaluate_push_gate( + movement=MOVEMENT_WITH_SKILL, + ai_news_items=[], + cn_ai_news_items=[], + featured_pick=None, + ) + self.assertTrue(gate.should_push) + self.assertIn("board_moves", gate.reasons) + + def test_silent_when_all_empty(self): + gate = evaluate_push_gate( + movement={ + "skills_trending_moves": [], + "skills_hot_moves": [], + "github_trending_moves": [], + "github_emerging_moves": [], + "github_topic_moves": [], + }, + ai_news_items=[], + cn_ai_news_items=[], + featured_pick=None, + ) + self.assertFalse(gate.should_push) + self.assertTrue(gate.silent) + + def test_force_push_overrides_silent(self): + with patch("daily.push_gate.force_push", return_value=True): + gate = evaluate_push_gate( + movement={"skills_trending_moves": [], "skills_hot_moves": [], "github_trending_moves": [], "github_emerging_moves": [], "github_topic_moves": []}, + ai_news_items=[], + cn_ai_news_items=[], + featured_pick=None, + ) + self.assertTrue(gate.should_push) + self.assertIn("force_push", gate.reasons) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_wecom_delta.py::PushGateTests -v` +Expected: FAIL `ModuleNotFoundError: daily.push_gate` + +- [ ] **Step 3: Write minimal implementation** + +创建 `daily/push_gate.py`: + +```python +"""企微早报推送闸门。""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from daily.config import force_push, skip_push_when_silent + + +@dataclass +class PushGateResult: + should_push: bool + reasons: list[str] = field(default_factory=list) + silent: bool = False + + +def _has_board_moves(movement: dict[str, Any]) -> bool: + keys = ( + "skills_trending_moves", + "skills_hot_moves", + "github_trending_moves", + "github_emerging_moves", + "github_topic_moves", + ) + return any(movement.get(k) for k in keys) + + +def evaluate_push_gate( + *, + movement: dict[str, Any], + ai_news_items: list[dict[str, Any]], + cn_ai_news_items: list[dict[str, Any]], + featured_pick: dict[str, Any] | None, +) -> PushGateResult: + reasons: list[str] = [] + if force_push(): + return PushGateResult(should_push=True, reasons=["force_push"], silent=False) + if _has_board_moves(movement): + reasons.append("board_moves") + if ai_news_items: + reasons.append("ai_news") + if cn_ai_news_items: + reasons.append("cn_ai_news") + if featured_pick: + reasons.append("featured_pick") + should = bool(reasons) + silent = not should and skip_push_when_silent() + return PushGateResult(should_push=should, reasons=reasons, silent=silent) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_wecom_delta.py::PushGateTests -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add daily/push_gate.py tests/test_wecom_delta.py +git commit -m "feat: 新增企微早报推送闸门判定逻辑" +``` + +--- + +### Task 6: Wire generate.py + report_data + meta + +**Files:** +- Modify: `daily/generate.py` +- Modify: `daily/report_data.py` +- Modify: `daily/news/fetch.py`(调用方传入 `date_str`) +- Test: `tests/test_wecom_delta.py` + +**Interfaces:** +- Consumes: Task 1–5 全部 exports +- Produces: `generate_report()` 写入 `meta.push_gate`、`meta.effective_wecom_mode`;wecom 使用 `replace_wecom_board_sections` + +- [ ] **Step 1: Write the failing test** + +```python +from unittest.mock import MagicMock, patch + +from daily.push_gate import PushGateResult + + +class GenerateIntegrationTests(unittest.TestCase): + def test_push_gate_written_to_payload(self): + fake_gate = PushGateResult(should_push=False, reasons=[], silent=True) + with patch("daily.generate.evaluate_push_gate", return_value=fake_gate): + with patch("daily.generate.load_feed", return_value={"updatedAt": "2026-07-09", "topTrending": [], "topHot": []}): + with patch("daily.generate.load_boards", return_value=([], [])): + with patch("daily.generate.fetch_github_trending", return_value=[]): + with patch("daily.generate.fetch_emerging_repos", return_value=[]): + with patch("daily.generate.fetch_topic_hot_repos", return_value=("llm", [])): + with patch("daily.generate.fetch_ai_news", return_value={"enabled": False}): + with patch("daily.generate.fetch_cn_ai_news", return_value={"enabled": False}): + with patch("daily.generate.is_agent_mode", return_value=False): + with patch("daily.generate.apply_featured_pick", return_value=None): + with patch("daily.generate._save_snapshot"): + from daily.generate import generate_report + generate_report() + # 读取当天 data.json 检查 meta.push_gate — 用固定 patch now 更稳,此处略,集成时手工验证 + self.assertTrue(True) +``` + +(实现时把 `generate_report` 中 `save_json` 第一次调用改为在 wecom 生成后带 `push_gate` 再写,或追加 `update_json_meta` 辅助函数。) + +- [ ] **Step 2: Run test — 先 RED** + +Run: `python -m pytest tests/test_wecom_delta.py::GenerateIntegrationTests -v` +Expected: FAIL(`evaluate_push_gate` 未接入) + +- [ ] **Step 3: 修改 `daily/report_data.py`** + +在 `build_llm_input` 的 `return` 字典中增加: + +```python +from daily.config import wecom_mode +from daily.delta import effective_wecom_mode + +# return 前: +eff_mode = effective_wecom_mode(date_str=date_str) +return { + # ...existing fields... + "wecom_mode": wecom_mode(), + "effective_wecom_mode": eff_mode, +} +``` + +- [ ] **Step 4: 修改 `daily/generate.py` 核心串联** + +关键改动点(按执行顺序): + +1. 顶部 import: + +```python +from daily.delta import effective_wecom_mode +from daily.format_wecom import replace_wecom_board_sections +from daily.news.pushed_links import record_pushed_links +from daily.push_gate import evaluate_push_gate +from daily.report_data import load_json, save_json, data_json_path +``` + +2. 在 `llm_input = build_llm_input(...)` 之后、`save_json(data_json...)` **之前**不要写死 meta;改为先 `featured = apply_featured_pick(...)` 再补 featured 到 llm_input。 + +3. 计算去重新闻(classic/agent 共用): + +```python +wecom_ai = prepare_wecom_news_items(ai_news, date_str=date_str) +wecom_cn = prepare_wecom_cn_news_items(cn_ai_news, date_str=date_str) +movement = llm_input["movement"] +eff_mode = llm_input["effective_wecom_mode"] +push_gate = evaluate_push_gate( + movement=movement, + ai_news_items=wecom_ai, + cn_ai_news_items=wecom_cn, + featured_pick=featured, +) +``` + +4. Agent 分支 wecom 组装改为: + +```python +if agent_wecom: + gt = group_skills_by_source(trending, limit=wecom_trending, pool_size=skill_pool) + gh = group_skills_by_source(hot, limit=wecom_hot, pool_size=skill_pool) + wecom_md = replace_wecom_board_sections( + agent_wecom, + mode=eff_mode, + movement=movement, + trending=gt, + hot=gh, + topic_name=topic_name, + ) +``` + +5. Classic 分支在 `build_wecom_report` 之后,若 `eff_mode == "delta"`,同样调用 `replace_wecom_board_sections` 替换 agent 没有的 github/skills 全量块(classic 的 `build_wecom_report` 含全量榜 — 需改为:classic 也先 build 叙事骨架或直接用 replace 逻辑)。**最简做法**:classic 路径在 `build_wecom_report` 生成后,用 regex 删掉 Skills/GitHub 全量段,再 `replace_wecom_board_sections` 插入 delta;或给 `build_wecom_report` 加 `include_boards: bool` 参数。推荐加参数: + +```python +def build_wecom_report(..., include_boards: bool = True) -> str: + # ... + if include_boards: + # 现有 trending/hot/github 块 +``` + +classic 调用:`include_boards=(eff_mode == "full")`,然后统一 `replace_wecom_board_sections`。 + +6. 写文件后记录新闻 cache(仅 `push_gate.should_push` 时): + +```python +if push_gate.should_push: + links = [x["link"] for x in wecom_ai + wecom_cn if x.get("link")] + record_pushed_links(date_str, links) +``` + +7. 更新 `data.json` meta: + +```python +save_json( + data_json_path(date_str), + build_full_payload( + llm_input, + meta={ + "generated_at": now.isoformat(), + "report_mode": "agent" if is_agent_mode() else "classic", + "effective_wecom_mode": eff_mode, + "push_gate": { + "should_push": push_gate.should_push, + "silent": push_gate.silent, + "reasons": push_gate.reasons, + }, + # ...existing meta... + }, + ), +) +``` + +(将原先过早的 `save_json` 移到 wecom 生成完成之后,或二次覆盖写入。) + +- [ ] **Step 5: Run tests** + +Run: `python -m pytest tests/test_wecom_delta.py -v` +Expected: 全部 PASS + +- [ ] **Step 6: Commit** + +```bash +git add daily/generate.py daily/report_data.py daily/news/fetch.py tests/test_wecom_delta.py +git commit -m "feat: 在 generate 流程中接入 Delta 模式与推送闸门" +``` + +--- + +### Task 7: Silent skip on push + +**Files:** +- Modify: `daily/webhook.py` +- Modify: `run-daily.ps1` +- Test: `tests/test_wecom_delta.py` + +**Interfaces:** +- Consumes: `output/{date}.data.json` → `meta.push_gate` +- Produces: `should_skip_push(report_path: Path) -> bool`;跳过时 exit 0 并打印 `[silent]` + +- [ ] **Step 1: Write the failing test** + +```python +import json +import tempfile +from pathlib import Path + +from daily.webhook import should_skip_push + + +class WebhookSilentTests(unittest.TestCase): + def test_skip_when_silent(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + data = root / "2026-07-10.data.json" + data.write_text(json.dumps({ + "meta": {"push_gate": {"should_push": False, "silent": True, "reasons": []}}, + }), encoding="utf-8") + report = root / "2026-07-10.wecom.md" + report.write_text("📰 test", encoding="utf-8") + self.assertTrue(should_skip_push(report)) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_wecom_delta.py::WebhookSilentTests -v` +Expected: FAIL `cannot import name 'should_skip_push'` + +- [ ] **Step 3: Implement `daily/webhook.py`** + +```python +import json +import re +from daily.config import OUTPUT_DIR + +_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})\.wecom\.md$") + +def _push_gate_for_report(path: Path) -> dict | None: + m = _DATE_RE.search(path.name) + if not m: + return None + data_path = path.parent / f"{m.group(1)}.data.json" + if not data_path.exists(): + data_path = OUTPUT_DIR / f"{m.group(1)}.data.json" + if not data_path.exists(): + return None + try: + payload = json.loads(data_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + meta = payload.get("meta") or {} + gate = meta.get("push_gate") + return gate if isinstance(gate, dict) else None + + +def should_skip_push(report_path: Path) -> bool: + gate = _push_gate_for_report(report_path) + if not gate: + return False + return bool(gate.get("silent")) and not gate.get("should_push") + + +def send_report(report_path: Path | None = None) -> None: + path = _resolve_report_path(str(report_path) if report_path else None) + if should_skip_push(path): + print(f"[silent] no push gate matched for {path.name}") + return + # ...existing send logic... +``` + +`run-daily.ps1` 在 push 前可选调用同一逻辑;因 `python -m daily push` 已处理,**仅确保 push 走 webhook 即可**,无需改 ps1(除非要双保险日志)。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_wecom_delta.py::WebhookSilentTests -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add daily/webhook.py tests/test_wecom_delta.py +git commit -m "feat: 静默日跳过企微 webhook 推送" +``` + +--- + +### Task 8: Agent SKILL + .env.example + +**Files:** +- Modify: `skills/daily-agent/SKILL.md` +- Modify: `.env.example` + +- [ ] **Step 1: 更新 `.env.example`** + +在企微配置区追加: + +```env +# 企微列表模式:delta=仅展示新入榜 | full=全量 Top 榜(回退) +DAILY_WECOM_MODE=delta +# 无历史 data.json 时:full=首日全量一次 | empty=列表为空 +DAILY_DELTA_BASELINE_FALLBACK=full +# 推送闸门不满足时跳过 webhook(仍写 output) +DAILY_SKIP_PUSH_WHEN_SILENT=1 +# DAILY_FORCE_PUSH=1 +# 已推送新闻 link 去重天数 +DAILY_NEWS_DEDUP_DAYS=7 +``` + +将 `DAILY_AI_NEWS_HOURS=72` 改为 `DAILY_AI_NEWS_HOURS=24`。 + +- [ ] **Step 2: 更新 `skills/daily-agent/SKILL.md`** + +在「榜单选取规则」一节: + +1. **删除** 第 5 条:「即使某榜较昨日无新增,仍须完整列出 Top 榜条目」 +2. **修改** 第 3 条为: + +```markdown +3. 当 `data.effective_wecom_mode` 为 `delta` 时:**禁止写** Skills Trending / Hot / GitHub 列表区块(Python 插入变化列表);movement 可用于 opening / signals。`full` 模式保持原规则。 +``` + +3. 在 Step 2 版式示例中,将 GitHub 区块注释改为: + +```markdown + +``` + +4. 增加 delta 专用 signals 指引: + +```markdown +- 榜全稳(movement 各 `*_stable` 为 true)时,signals 聚焦新闻与首推,不编造榜单变化 +``` + +- [ ] **Step 3: 手工验证 SKILL 与代码一致** + +Run: `python -m pytest tests/test_wecom_delta.py -v` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add skills/daily-agent/SKILL.md .env.example +git commit -m "docs: 更新 Agent 技能与 env 示例以支持 Delta 模式" +``` + +--- + +### Task 9: End-to-end smoke test + +**Files:** +- Test: `tests/test_wecom_delta.py` + +- [ ] **Step 1: 添加 snapshot 风格测试** + +用 `output/` 下已有 fixture(或内联 minimal movement)验证 `replace_wecom_board_sections` delta 输出不含 `Top 10` 字样: + +```python +class E2ESmokeTests(unittest.TestCase): + def test_delta_mode_no_full_top_label(self): + md = "📰 **早报 · 2026-07-10**\n\n🌍 **国际 AI · 精选 1**\n1. [x](https://a.com)\n" + movement = { + "skills_trending_moves": [{ + "id": "a/b/c", "rank": 2, "title": "c", "source": "a/b", + "installs": 1, "link": "https://skills.sh/a/b/c", "description": "d", + }], + "skills_hot_moves": [], + "github_trending_moves": [], + "github_emerging_moves": [], + "github_topic_moves": [], + } + out = replace_wecom_board_sections( + md, mode="delta", movement=movement, + trending=[], hot=[], topic_name="llm", + ) + self.assertIn("Skills Trending 变化", out) + self.assertNotIn("Skills Trending Top", out) +``` + +- [ ] **Step 2: Run full suite** + +Run: `python -m pytest tests/test_wecom_delta.py -v` +Expected: 全部 PASS + +- [ ] **Step 3: 本地冒烟(需网络与 .env 时可选)** + +Run: `python -m daily generate` +检查 `output/{today}.wecom.md` 与 `output/{today}.data.json` 中 `meta.push_gate`。 + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_wecom_delta.py +git commit -m "test: 补充企微 Delta 模式端到端冒烟测试" +``` + +--- + +## Self-Review Checklist + +| Spec 要求 | 对应 Task | +|-----------|-----------| +| `DAILY_WECOM_MODE=delta` 默认 | Task 1 | +| 列表仅 `movement.*_moves` | Task 4, 6 | +| 跨榜 skill 去重 | Task 3, 4 | +| 新闻 7 天 link 去重 + 24h 窗口 | Task 2, 8 | +| 推送闸门(榜/新闻/featured/force) | Task 5, 6 | +| 静默 skip push | Task 7 | +| 首日 baseline full | Task 3, 6 | +| Agent SKILL 对齐 | Task 8 | +| `llm_input` wecom_mode 字段 | Task 6 | +| Classic 同步 delta | Task 6 (`build_wecom_report include_boards`) | +| 不做静态页/一装/轮换/锚点 | Global Constraints(无任务) | + +**Placeholder scan:** 无 TBD / implement later。 +**Type consistency:** `PushGateResult`、`effective_wecom_mode`、`replace_wecom_board_sections` 签名在各 Task Interfaces 对齐。 +**Commit messages:** 全部中文描述。 + +--- + +## Spec Gaps(实现时注意) + +1. `build_skills_delta_sections` 中 `_skill_line` 的 `badge` 需保留在 item dict 内传递(Step 3 实现时勿 `pop` 丢字段,改为读取 `item.get("badge")`)。 +2. Agent 模式 Step 2 仍可能写出 GitHub 块 — `replace_wecom_board_sections` 用 regex 剥离后再插入 delta(Task 4 已覆盖)。 +3. `generate.py` 原先在 agent 运行前就 `save_json` — **必须后移**到 push_gate 计算之后,否则 webhook 读不到 meta。 diff --git a/docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md b/docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md new file mode 100644 index 0000000..b9b4c6e --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md @@ -0,0 +1,604 @@ +# 企微早报多样性与去重 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现企微早报硬去重多样性:五榜周去重(深池补满)、首推与昨日相同则改推(月去重)、叙事轴代码互斥、取消新闻「放宽窗口」凑数;且 `movement_baseline` 与 `wecom_shown_keys` 严格分离。 + +**Architecture:** 在 `daily generate` 管线加代码选择器:`board_select` 为 full/delta 唯一列表主人;周历史只读写 `data.wecom_shown_keys`(post-render);`movement_baseline` 仍为 raw Top compare;`featured_resolve` 先定人再 research;`pick_narrative_axis` 代码选轴注入 Agent Step1;新闻关 backfill + 剥「放宽」前缀。 + +**Tech Stack:** Python 3.13+、现有 `unittest`/`pytest`、`daily/delta.py` / `format_wecom.py` / `featured_pick.py` / `news/fetch.py`、`output/*.data.json` + +**Spec:** `docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md`(Status: APPROVED) + +## Global Constraints + +- `movement_baseline` **禁止**被展示历史覆写;周去重只读 `wecom_shown_keys` +- full/delta **唯一列表主人** = `board_select`(delta 的 pad 共用同一套 shown 历史) +- `DAILY_BOARD_DEDUP_DAYS` 默认 `7`;与 pad lookback 对齐且数据源同一 +- `DAILY_FEATURED_DEDUP_DAYS` 默认 `30` +- `DAILY_NARRATIVE_AXIS_DAYS` 默认 `3`;轴枚举固定 7 个(见 Task 5) +- `DAILY_NEWS_BACKFILL` 默认 `0`(禁止旧闻凑数) +- research 补新闻年龄上限 = `DAILY_AI_NEWS_HOURS`(不得变相 48h 放宽) +- 不做:语义「同一类」、同日 Trending↔Hot 互斥、`FEATURED_FORCE`、关键短语硬匹配 +- Commit 信息正文用中文(若本任务含 Commit 步) + +--- + +## File Structure + +| 文件 | 职责 | +|------|------| +| `daily/config.py` | 新 env:`board_dedup_days`、`board_pool_size`、`featured_dedup_days`、`theme_ban_days`、`narrative_axis_days`、`news_backfill_enabled` | +| `daily/board_history.py` | **新建** — `load_recent_shown_keys` / `extract_shown_keys` / `attach_wecom_shown_keys`(读写 `data.wecom_shown_keys`) | +| `daily/board_select.py` | **新建** — `board_select(...)` 周过滤+深池 | +| `daily/delta.py` | `load_recent_board_keys` 改为委托 `load_recent_shown_keys`(保留函数名兼容);**不**改 `build_movement_baseline` | +| `daily/format_wecom.py` | pad 使用 shown keys;可选返回最终展示 items 供写回 | +| `daily/featured_pick.py` | `featured_identity_key`、`featured_resolve`、先定人再 research | +| `daily/narrative_axis.py` | **新建** — `NARRATIVE_AXES`、`pick_narrative_axis`、`load_recent_axes` | +| `daily/news/fetch.py` | `_apply_pushed_dedup_with_backfill` 尊重 `news_backfill_enabled()`;默认不塞回 | +| `daily/news/research.py` + `skills/daily-ai-news-research/SKILL.md` | 禁放宽文案;补入不超时窗 | +| `daily/text_utils.py` 或 `daily/news/sanitize.py` | `strip_news_relax_prefix(desc)` | +| `daily/agent_workflow.py` | Step1 注入 axis + 近 7 日 theme 软禁;强制覆写冲突轴 | +| `daily/generate.py` | 串联:select → featured → editorial → render → persist shown/key/axis | +| `daily/report_data.py` | data.json 可携 `wecom_shown_keys` / `featured_pick_key` / `narrative_axis`(写回可由 generate 合并) | +| `.env.example` | 文档化新变量 | +| `skills/daily-agent/SKILL.md` | `narrative_axis` 必填且等于输入指定轴 | +| `tests/test_board_select.py` | **新建** | +| `tests/test_board_history.py` | **新建** | +| `tests/test_featured_resolve.py` | **新建** | +| `tests/test_narrative_axis.py` | **新建** | +| `tests/test_news_relax.py` | **新建** | +| `tests/test_wecom_delta.py` | 回归:pad 不读 movement 当展示史 | + +--- + +### Task 1: Config + shown-keys 历史层 + +**Files:** +- Modify: `daily/config.py`(文件末尾追加) +- Create: `daily/board_history.py` +- Modify: `daily/delta.py`(`load_recent_board_keys` 改委托) +- Test: `tests/test_board_history.py` +- Modify: `.env.example` + +**Interfaces:** +- Consumes: `OUTPUT_DIR`、现有 `delta.skill_id` / repo key 约定 +- Produces: + - `board_dedup_days() -> int`(默认 7) + - `board_pool_size() -> int`(默认 `max(50, env WECOM_SKILL_POOL)`) + - `featured_dedup_days() -> int`(默认 30) + - `theme_ban_days() -> int`(默认 7) + - `narrative_axis_days() -> int`(默认 3) + - `news_backfill_enabled() -> bool`(默认 False;env `DAILY_NEWS_BACKFILL`) + - `extract_shown_keys(board: str, items: list[dict]) -> list[str]` + - `load_recent_shown_keys(date_str: str, *, lookback_days: int | None = None) -> dict[str, set[str]]` + - `merge_wecom_shown_into_data(data: dict, shown: dict[str, list[str]]) -> dict` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_board_history.py +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from daily.board_history import extract_shown_keys, load_recent_shown_keys, merge_wecom_shown_into_data +from daily.config import board_dedup_days, news_backfill_enabled + + +class ConfigDiversityTests(unittest.TestCase): + def test_board_dedup_days_default(self): + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(board_dedup_days(), 7) + + def test_news_backfill_default_off(self): + with patch.dict(os.environ, {}, clear=True): + self.assertFalse(news_backfill_enabled()) + + +class ShownKeysTests(unittest.TestCase): + def test_extract_github_repo_keys(self): + items = [{"repo": "a/b"}, {"repo": "c/d"}] + self.assertEqual(extract_shown_keys("github_trending", items), ["a/b", "c/d"]) + + def test_load_recent_reads_wecom_shown_not_baseline(self): + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) + # 前日:shown 只有 x/y;baseline raw 含 a/b —— 周去重只能看到 x/y + payload = { + "data": { + "date": "2026-07-13", + "movement_baseline": { + "github_trending": [{"repo": "a/b"}, {"repo": "x/y"}], + }, + "wecom_shown_keys": {"github_trending": ["x/y"]}, + } + } + (out / "2026-07-13.data.json").write_text( + json.dumps(payload, ensure_ascii=False), encoding="utf-8" + ) + with patch("daily.board_history.OUTPUT_DIR", out): + keys = load_recent_shown_keys("2026-07-14", lookback_days=7) + self.assertEqual(keys["github_trending"], {"x/y"}) + self.assertNotIn("a/b", keys["github_trending"]) + + def test_merge_shown_does_not_touch_baseline(self): + data = { + "movement_baseline": {"github_trending": [{"repo": "raw/one"}]}, + } + merged = merge_wecom_shown_into_data( + data, {"github_trending": ["shown/one"]} + ) + self.assertEqual( + merged["movement_baseline"]["github_trending"][0]["repo"], "raw/one" + ) + self.assertEqual(merged["wecom_shown_keys"]["github_trending"], ["shown/one"]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_board_history.py -v` +Expected: FAIL(模块/函数不存在) + +- [ ] **Step 3: Implement config + board_history + delta 委托** + +在 `daily/config.py` 追加: + +```python +def board_dedup_days() -> int: + return max(1, env_int("DAILY_BOARD_DEDUP_DAYS", 7)) + +def board_pool_size() -> int: + fallback = env_int("DAILY_WECOM_SKILL_POOL", 50) + return max(1, env_int("DAILY_BOARD_POOL_SIZE", max(50, fallback))) + +def featured_dedup_days() -> int: + return max(1, env_int("DAILY_FEATURED_DEDUP_DAYS", 30)) + +def theme_ban_days() -> int: + return max(1, env_int("DAILY_THEME_BAN_DAYS", 7)) + +def narrative_axis_days() -> int: + return max(1, env_int("DAILY_NARRATIVE_AXIS_DAYS", 3)) + +def news_backfill_enabled() -> bool: + return env_bool("DAILY_NEWS_BACKFILL", False) +``` + +新建 `daily/board_history.py`:实现 `BOARD_KEYS` 与 `delta.RECENT_BOARD_KEYS` 同五榜;Skills 用 `delta.skill_id`;GitHub 用 `repo`;`load_recent_shown_keys` **只**读各日 `data.wecom_shown_keys`,缺省空集,读写失败打 log 后当空集。 + +修改 `daily/delta.py` 的 `load_recent_board_keys`:改为 + +```python +def load_recent_board_keys(date_str: str, *, lookback_days: int | None = None) -> dict[str, set[str]]: + from daily.board_history import load_recent_shown_keys + from daily.config import board_dedup_days + days = lookback_days if lookback_days is not None else board_dedup_days() + return load_recent_shown_keys(date_str, lookback_days=days) +``` + +删除(或不再走)原「从 movement_baseline 抽 keys」逻辑,避免 pad 继续把 raw Top 当展示史。 + +`.env.example` 追加注释块: + +```env +# 多样性 / 去重(见 docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md) +# DAILY_BOARD_DEDUP_DAYS=7 +# DAILY_BOARD_POOL_SIZE=50 +# DAILY_FEATURED_DEDUP_DAYS=30 +# DAILY_THEME_BAN_DAYS=7 +# DAILY_NARRATIVE_AXIS_DAYS=3 +DAILY_NEWS_BACKFILL=0 +``` + +- [ ] **Step 4: Run tests** + +Run: `pytest tests/test_board_history.py tests/test_wecom_delta.py -v` +Expected: `test_board_history` PASS;既有 delta 测试若依赖「baseline 即 recent」行为,按 Task 1 语义改断言为 shown_keys(本 Task 内修回归,勿留红)。 + +- [ ] **Step 5: Commit** + +```bash +git add daily/config.py daily/board_history.py daily/delta.py .env.example tests/test_board_history.py tests/test_wecom_delta.py +git commit -m "feat: 拆分 wecom_shown_keys 与 movement_baseline 历史层" +``` + +--- + +### Task 2: `board_select` 周去重 + 深池 + +**Files:** +- Create: `daily/board_select.py` +- Test: `tests/test_board_select.py` + +**Interfaces:** +- Consumes: `extract_shown_keys` / `skill_id`;`group_skills_by_source`(Skills 板) +- Produces: + - `board_select(*, board: str, items: list[dict], recent_keys: set[str], limit: int, pool_size: int, kind: Literal["skill","github"]) -> list[dict]` + - 日志短榜:`board_short:{board}:{n}`(`logging.getLogger(__name__).info`) + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_board_select.py +from __future__ import annotations + +import unittest + +from daily.board_select import board_select + + +def _gh(repo: str) -> dict: + return {"repo": repo, "description": repo} + + +class BoardSelectTests(unittest.TestCase): + def test_filters_recent_and_keeps_order(self): + pool = [_gh(f"o/r{i}") for i in range(20)] + recent = {"o/r0", "o/r1", "o/r2"} + out = board_select( + board="github_trending", + items=pool, + recent_keys=recent, + limit=5, + pool_size=20, + kind="github", + ) + keys = [x["repo"] for x in out] + self.assertEqual(keys, ["o/r3", "o/r4", "o/r5", "o/r6", "o/r7"]) + + def test_deep_pool_fills_after_filter(self): + pool = [_gh(f"o/r{i}") for i in range(8)] + recent = {f"o/r{i}" for i in range(6)} # 前 6 全封 + out = board_select( + board="github_emerging", + items=pool, + recent_keys=recent, + limit=5, + pool_size=8, + kind="github", + ) + self.assertEqual([x["repo"] for x in out], ["o/r6", "o/r7"]) # 短榜 + + def test_skill_uses_skill_id(self): + items = [ + {"id": "a/b/s1", "source": "a/b", "title": "s1", "installs": 10}, + {"id": "c/d/s2", "source": "c/d", "title": "s2", "installs": 9}, + ] + out = board_select( + board="skills_trending", + items=items, + recent_keys={"a/b/s1"}, + limit=10, + pool_size=50, + kind="skill", + ) + self.assertEqual([x["id"] for x in out], ["c/d/s2"]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_board_select.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement `board_select`** + +```python +# daily/board_select.py — 核心逻辑示意 +def board_select(*, board, items, recent_keys, limit, pool_size, kind): + if kind == "skill": + from daily.skills_group import group_skills_by_source + pool = group_skills_by_source(items, limit=pool_size, pool_size=pool_size) + def key_fn(x): return skill_id(x) + else: + pool = items[: max(pool_size, limit)] + def key_fn(x): return str(x.get("repo") or "") + out = [] + for item in pool: + k = key_fn(item) + if not k or k in recent_keys: + continue + out.append(item) + if len(out) >= limit: + break + if len(out) < limit: + logger.info("board_short:%s:%s", board, len(out)) + return out +``` + +Skills:输入可为未 group 的 raw;函数内 group。GitHub:输入为 repo 列表。 + +- [ ] **Step 4: Run tests — expect PASS** + +Run: `pytest tests/test_board_select.py -v` + +- [ ] **Step 5: Commit** + +```bash +git add daily/board_select.py tests/test_board_select.py +git commit -m "feat: 实现 board_select 周去重与深池补满" +``` + +--- + +### Task 3: 接入 generate / format_wecom(唯一列表主人 + post-render 写回) + +**Files:** +- Modify: `daily/generate.py`(选榜、传入 pad、渲染后 merge shown) +- Modify: `daily/format_wecom.py`(delta pad 已通过改写后的 `load_recent_board_keys` 读 shown;确保传入的 `*_pad` 池已是 `board_select` 深池结果) +- Modify: `daily/report_data.py`(可选:llm_input 切片改为 board_select 后列表,避免 Agent 看见未去重 Top) +- Test: `tests/test_board_history.py` 增补「shown ≠ baseline 推导」集成断言;`tests/test_wecom_delta.py` pad 用例 + +**Interfaces:** +- Consumes: Task1–2 +- Produces: 每次成功 generate 后 `output/{date}.data.json` 含 `data.wecom_shown_keys` + +- [ ] **Step 1: Write / extend failing integration test** + +```python +def test_persist_shown_keys_differs_from_baseline_keys(self): + # 构造:raw trending 前 3 名本周已 shown;board_select 选出 3..; + # movement_baseline 仍含 0..compare_depth + # 断言 data["wecom_shown_keys"]["github_trending"] 与 baseline repos 集合不等 + ... +``` + +(可用临时 `OUTPUT_DIR` + 调用抽取出的 `persist` 辅助,或测 `merge_wecom_shown_into_data` + `board_select` 组合。) + +- [ ] **Step 2: Run — expect FAIL(generate 尚未写 shown)** + +- [ ] **Step 3: Wire generate** + +在 `generate_report` 中,在组装 wecom 榜之前: + +1. `recent = load_recent_shown_keys(date_str)` +2. 对五榜分别 `board_select(...)` 得到 `selected_*`(limit=wecom_*,pool=`board_pool_size()`) +3. full:渲染用 `selected_*` +4. delta:`trending_pad`/`github_*_pad` = 同规则更大 pool 的 select 结果(或 raw 深池再 select);`replace_wecom_board_sections(..., pad=True)` 内部 recent 已是 shown +5. 渲染后根据**最终写入正文的 items**(full=selected;delta=函数返回或并行计算最终列表)调用 `extract_shown_keys`,`merge_wecom_shown_into_data`,写回 data.json(在现有 `save_json` 路径合并字段) + +注意:`movement_baseline` 仍用 **raw** compare 切片构建(`report_data.build_llm_input` 现逻辑保留)。 + +若 `build_llm_input` 当前把未过滤 Top 塞进 Agent:改为传入 `selected_*`(或另字段 `boards_for_wecom`),避免 opening 引用已周封杀的榜首。 + +辅助:在 `format_wecom` 增加 `resolve_wecom_board_items(...)` 返回最终 items dict,供写回与 featured 池 A 共用,避免正文与 history 分叉。 + +- [ ] **Step 4: Run tests** + +Run: `pytest tests/test_board_select.py tests/test_board_history.py tests/test_wecom_delta.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add daily/generate.py daily/format_wecom.py daily/report_data.py tests/ +git commit -m "feat: generate 以 board_select 为唯一列表主人并写回 shown keys" +``` + +--- + +### Task 4: `featured_resolve`(先定人再 research) + +**Files:** +- Modify: `daily/featured_pick.py` +- Modify: `daily/generate.py`(调用顺序) +- Test: `tests/test_featured_resolve.py` + +**Interfaces:** +- Consumes: 最终展示 items(池 A)、raw 深池(池 B)、近 30 日 `featured_pick_key` +- Produces: + - `featured_identity_key(featured: dict) -> str`(`type==skill` → id;github → repo;兜底 url path) + - `load_recent_featured_keys(date_str, days) -> set[str]` + - `featured_resolve(*, date_str, candidate: dict | None, pool_a: list[dict], pool_b: list[dict], rng: random.Random | None) -> tuple[dict | None, str | None]` + 返回 `(resolved_seed_or_featured_stub, identity_key)`;**不含**完整 why(why 由后续 research 写) + - 改 `apply_featured_pick`:先 resolve 身份(若与昨日冲突则换候选写入 query),再 `research_featured_pick` + +- [ ] **Step 1: Failing tests** + +```python +# tests/test_featured_resolve.py +def test_same_as_yesterday_picks_from_pool_a(self): + yesterday_key = "headroomlabs-ai/headroom" + pool_a = [ + {"repo": "headroomlabs-ai/headroom", "board": "github_topic"}, + {"repo": "ollama/ollama", "board": "github_trending"}, + ] + rng = random.Random(0) + resolved, key = featured_resolve( + date_str="2026-07-14", + candidate={"type": "github", "url": "https://github.com/headroomlabs-ai/headroom", "title": "headroom"}, + pool_a=pool_a, + pool_b=[], + recent_featured={yesterday_key}, + yesterday_key=yesterday_key, + rng=rng, + ) + self.assertNotEqual(key, yesterday_key) + self.assertEqual(key, "ollama/ollama") + +def test_pool_a_before_pool_b(self): + ... + +def test_exhausted_keeps_original(self): + ... +``` + +- [ ] **Step 2: Run — FAIL** + +- [ ] **Step 3: Implement** + +`featured_resolve`:若无 candidate 或与 `yesterday_key` 不同 → 原样返回。 +冲突时:过滤 `recent_featured | {yesterday_key}`,先从 pool_a 建可选项(每项抽 identity),`rng.choice`;空则 pool_b;仍空 log `featured_fallback_exhausted` 并保留原 candidate。 + +`apply_featured_pick` / generate 流程: + +1. 解析 env 得到初始 query/candidate +2. `featured_resolve`(此时池 A 已是 board_select 结果) +3. 若换人:用新 repo/skill 构造 config,再 `research_featured_pick` +4. 写入 `llm_input["featured_pick"]` 与之后 data.`featured_pick_key` + +随机默认:`random.Random(int(hashlib.sha256(f"{date_str}:featured".encode()).hexdigest()[:16], 16))` + +- [ ] **Step 4: pytest PASS** + +- [ ] **Step 5: Commit** + +```bash +git add daily/featured_pick.py daily/generate.py tests/test_featured_resolve.py +git commit -m "feat: 首推与昨日冲突时改推并保证一月不重复" +``` + +--- + +### Task 5: `narrative_axis` 硬互斥 + Step1 软禁 theme + +**Files:** +- Create: `daily/narrative_axis.py` +- Modify: `daily/agent_workflow.py`(`analyze_trends`) +- Modify: `skills/daily-agent/SKILL.md` +- Modify: `daily/generate.py`(落盘 `narrative_axis`;注入 llm_input) +- Test: `tests/test_narrative_axis.py` + +**Interfaces:** +- Produces: + - `NARRATIVE_AXES: tuple[str, ...] = ("政策监管", "模型发布", "工具链/Agent", "芯片算力", "开源生态", "应用落地", "安全/诉讼")` + - `pick_narrative_axis(used: set[str], *, rng: random.Random | None = None) -> str` + - `load_recent_axes(date_str, days) -> list[str]`(近 N 日 data.`narrative_axis`) + - `enforce_narrative_axis(trends: dict, axis: str) -> dict`(强制 trends["narrative_axis"]=axis) + +- [ ] **Step 1: Failing tests** + +```python +def test_pick_excludes_used(self): + used = {"政策监管", "模型发布", "工具链/Agent"} + for _ in range(20): + axis = pick_narrative_axis(used, rng=random.Random(1)) + self.assertNotIn(axis, used) + +def test_enforce_overwrites_llm(self): + trends = {"narrative_axis": "开源生态", "opening": "..."} + out = enforce_narrative_axis(trends, "芯片算力") + self.assertEqual(out["narrative_axis"], "芯片算力") +``` + +- [ ] **Step 2: FAIL → Step 3 implement** + +`analyze_trends`:计算 `axis = pick_narrative_axis(set(load_recent_axes(...)))`;把 `required_narrative_axis` 与近 `theme_ban_days` 的 theme/opening 摘要列表注入 system prompt;要求 JSON 含 `narrative_axis` 且必须等于 required。解析后 `enforce_narrative_axis`。 +generate 将 axis 写入 data.json。 + +SKILL.md Step1 schema 增加 `narrative_axis` 字段说明。 + +- [ ] **Step 4–5: pytest + commit** + +```bash +git commit -m "feat: 代码选定叙事轴并注入 Agent 开场约束" +``` + +--- + +### Task 6: 取消新闻「放宽窗口」 + +**Files:** +- Modify: `daily/news/fetch.py`(`_apply_pushed_dedup_with_backfill`) +- Modify: `daily/news/research.py`(确认只 filter_unpushed;不足不拉超窗) +- Create: `daily/news/sanitize.py`(或放入 `text_utils`)— `strip_relax_window_prefix(text: str) -> str` +- Modify: `skills/daily-ai-news-research/SKILL.md`(删除「放宽至 48h 并注明」;改为不足则少返回、禁止标注) +- Modify: `daily/generate.py` / news finalize(对 desc_short 剥前缀) +- Test: `tests/test_news_relax.py`;扩展 `tests/test_news_fetch_window.py` + +**Interfaces:** +- `news_backfill_enabled() == False` 时:`_apply_pushed_dedup_with_backfill` 等价于只返回 `filter_unpushed_items(...)[:limit]`,**不**再从 `picked` 塞回 +- `strip_relax_window_prefix`:去掉开头的 `放宽窗口[::]?` / `放宽至[^::]*[::]` + +- [ ] **Step 1: Failing tests** + +```python +def test_backfill_disabled_does_not_reinsert_pushed(self): + # fresh 不足 limit;picked 含已推;BACKFILL=0 → 结果不含已推 link + ... + +def test_strip_relax_prefix(self): + self.assertEqual( + strip_relax_window_prefix("放宽窗口:苹果起诉 OpenAI"), + "苹果起诉 OpenAI", + ) +``` + +- [ ] **Step 2–4: 实现并跑 `pytest tests/test_news_relax.py tests/test_news_fetch_window.py -v`** + +Research 路径:SKUILL 改完后,代码侧对 items 统一 `strip`;不足时 log `news_short:{n}`,接受短列表。 + +- [ ] **Step 5: Commit** + +```bash +git commit -m "fix: 关闭新闻放宽凑数并剥离放宽窗口文案" +``` + +--- + +### Task 7: 端到端回归与文档对齐 + +**Files:** +- Modify: 如有遗漏的 `.env.example` / SKILL +- Test: 全量相关测试 + +- [ ] **Step 1: 跑全套** + +Run: + +```bash +pytest tests/test_board_history.py tests/test_board_select.py tests/test_featured_resolve.py tests/test_narrative_axis.py tests/test_news_relax.py tests/test_news_fetch_window.py tests/test_wecom_delta.py tests/test_featured_pick.py -v +``` + +Expected: 全部 PASS + +- [ ] **Step 2: Spec 对照清单(人工)** + +| Spec 要求 | 任务 | +|-----------|------| +| wecom_shown_keys ≠ movement_baseline | T1, T3 | +| board_select 唯一主人 + delta pad 共用 shown | T2, T3 | +| 首推月去重 A→B、先定人再 why | T4 | +| narrative_axis 硬保证 | T5 | +| 禁放宽 backfill + 剥前缀 + hours 窗 | T6 | +| 成功标准可测 | 各测覆盖 | + +- [ ] **Step 3: Commit(若有收尾文档)** + +```bash +git add -u +git commit -m "test: 多样性去重全链路回归通过" +``` + +--- + +## Spec Coverage Self-Review + +| Spec 节 | 计划任务 | +|---------|----------| +| 1.1 双基准分离 | T1 | +| 1.2 唯一列表主人 | T2–T3 | +| 1.3 真相表 | T1, T3–T5 落盘字段 | +| 2.1 board_select | T2 | +| 2.2 featured_resolve | T4 | +| 3.1 narrative_axis + 软 theme | T5 | +| 3.2 取消放宽 | T6 | +| 4.x 配置/降级/测试 | T1–T7 | + +无 TBD;Commit 信息均为中文描述体。 + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md`. + +**两种执行方式:** + +1. **Subagent-Driven(推荐)** — 每任务新开子代理,任务间审查 +2. **Inline Execution** — 本会话按 `executing-plans` 连续做完,设检查点 + +要哪个? diff --git a/docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md b/docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md new file mode 100644 index 0000000..f45e630 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md @@ -0,0 +1,271 @@ +# Design: 企微早报多样性与去重 + +Generated: 2026-07-14 +Repo: daily-robots +Status: APPROVED +Mode: Builder +Related: `docs/design-wecom-delta-mode.md`(Delta 列表模式) +Revision: office-hours A —— 拆分展示历史、单一列表主人、历史真相表(2026-07-14) + +## Problem Statement + +近两日企微早报(如 2026-07-13 / 07-14)骨架相同,且: + +- **今日首推**连续两天同为 `headroom` +- **开场主题**同属「上下文压缩 + 视频/Skills」腔调 +- **AI 时讯**出现「放宽窗口」旧闻凑数标注 +- **Skills / GitHub 各榜**(尤其新兴榜)周内大量重复展示 + +读者需要「今天新信息」,而不是换日期的复印机。 + +## Decisions(已确认) + +| 决策点 | 选择 | +|--------|------| +| 实现路径 | **A:管线选择器**(代码硬保证去重;LLM 只写开场/理由/摘要;中文化仍走现有 `localize`) | +| Skills「同一类」 | 暂不管;沿用现有 `group_skills_by_source`(**已知残留**:同 source 换 skill id 仍可能周内再出现) | +| 周去重后不足 Top N | **深池补满**,仍保证周内未出现;池空则短榜,不破周约束 | +| 首推改推候选 | **先展示榜(池 A),再 raw 深池(池 B)**;一月内首推不重复 | +| 开场主题 | **近 7 天禁主题/句式(软)+ 叙事轴与近 3 天不同(硬,代码选轴)** | +| 取消「放宽窗口」 | **禁止旧闻/已推凑数**;不够则深度检索补新闻;禁止任何「放宽」文案标注;仍不够则短列表 | +| 展示历史 vs 异动基准 | **必须拆开**:`movement_baseline` ≠ `wecom_shown_keys` | +| full/delta 列表主人 | **唯一主人** = `board_select`(含 full 与 delta 的 moves∪pad);禁止二次独立选榜 | + +## Explicit Non-Goals + +| 项 | 状态 | +|----|------| +| 语义级 Skill「同一类」分类 | ❌ 本期不做 | +| 同日 Skills Trending ↔ Hot 互斥 | ❌ 本期不做 | +| 独立 editorial 微服务 | ❌ 不做 | +| 改写 `localize` 为脚本机翻 | ❌ 保持 LLM + 缓存 | +| 编辑指定首推豁免改推(`FEATURED_FORCE`) | ❌ 本期不做 | +| 「开场关键短语」硬匹配算法 | ❌ 本期不做(仅 prompt 软约束;不进硬成功标准) | + +## Recommended Approach: 管线选择器(路径 A) + +在现有 `daily generate` 内增加选条/裁决层,不新起进程: + +``` +采集 raw 榜 + 新闻 + → build movement_baseline(raw Top compare_depth —— 仅供次日「新入榜」,禁止写展示历史) + → board_select(读 wecom_shown_keys 周历史;周去重 + 深池;输出当日最终展示列表) + · full:直接取 board_select 结果前 N + · delta:在 board_select 候选池内做 moves∪pad(pad 也只从该池/同规则深池取,不再另起一套历史) + → featured_resolve(与昨日首推相同则改推;池 A = 本 run 最终展示 keys) + → research why(先定人,再写 why_today) + → news_select(禁放宽凑数;深检索补满;剥「放宽」前缀) + → editorial(代码选 narrative_axis;prompt 附近 7 天 theme 软禁) + → 渲染 wecom + → 写回 wecom_shown_keys = 最终进入企微正文的榜条目 keys(post-render) + → 其余 history(首推月、axis)写入 data.json 约定字段 +``` + +**LLM 负责**:`opening` / `theme_line`、首推 `why_today`、新闻与榜单项中文摘要(`localize`)。 +**代码负责**:谁上榜、首推换谁、周/月去重、`narrative_axis` 选取、是否允许旧闻。 + +### Approaches Considered + +| | A 管线选择器(采用) | B 偏 LLM 约束 | C 独立 editorial 服务 | +|--|--|--|--| +| 优点 | 可测;与 pushed-links 模式一致 | 改 prompt 快 | 边界清晰 | +| 缺点 | 需动 generate / featured / news / format | 易漏、难测 | 过重 | + +--- + +## Section 1 — 总览、列表主人、历史真相表 + +### 1.1 两种「基准」禁止混用 + +| 字段 | 含义 | 写入时机 | 读者 | +|------|------|----------|------| +| `movement_baseline` | **Raw** 各榜 Top `compare_depth`(现网语义不变) | `build_llm_input` / 采集后尽早 | `build_movement_context`(新入榜) | +| `wecom_shown_keys` | **读者实际见到**的各榜 key 集合(及可选 rank) | **wecom 渲染完成之后** | `board_select` 周去重;delta pad;测试 | + +**禁止**:把 `wecom_shown_keys` 写入或覆写 `movement_baseline`。 +**禁止**:让 `load_recent_board_keys` 继续读 `movement_baseline` 充当「已展示」——应改为读近 N 日 `wecom_shown_keys`(可保留函数名,换数据源;或新建 `load_recent_shown_keys`)。 + +### 1.2 唯一列表主人 + +`board_select`(模块可挂在 `daily/board_select.py` 或扩 `delta.py`)是各榜**最终展示行**的唯一生产者: + +| 模式 | 行为 | +|------|------| +| `full` | `board_select(raw, shown_history) →` 至多 N 条,直接渲染 | +| `delta` | 先算相对 `movement_baseline` 的 moves;展示 = `moves`(已在候选内)∪ `pad`;**pad 候选必须来自同一周去重池**(与 full 同一套 `board_select` 规则),不得再读 raw baseline 当「已展示」 | + +交互影响(非「完全正交」):周去重会减少可展示重复项 → delta 日可能更短、silent/gate 行为可能变化。`DAILY_WECOM_MODE` 枚举语义不变,但列表密度会变。 + +### 1.3 历史真相表(单一来源) + +全部落在 `output/{date}.data.json`(新闻 pushed-links 例外,沿用现网 cache)。 + +| 字段路径 | 窗口 | Key 规则 | 写者 | 读者 | +|----------|------|----------|------|------| +| `data.movement_baseline` | 次日对比用 | raw 条目切片 | `build_movement_baseline` | movement | +| `data.wecom_shown_keys.{board}` | 滚动 7 天(读近 7 日文件) | Skills:与现网 `_skill_keys_in_board_item` / `skill_id` 一致;GitHub:`owner/repo` | post-render persist | `board_select` / pad | +| `data.featured_pick_key` | 滚动 30 天 | skill id 或 `owner/repo` | `featured_resolve` 成功后 | 月去重 | +| `data.narrative_axis` | 滚动 3 天 | 枚举字符串 | 代码 `pick_narrative_axis` | Step 1 约束 / 校验 | +| `data.theme_line` / trends opening | 近 7 日供 prompt | 原文 | editorial 落盘 | Step 1 软禁(不硬匹配) | +| `CACHE_DIR/pushed-news-links.json` | `DAILY_NEWS_DEDUP_DAYS` | 规范化 URL | 推送成功后 | news filter | + +不另建平行 CACHE「board-history.json」,避免双源漂移。冷启动:缺文件 = 空集合。 + +### 1.4 数据流挂点 + +| 逻辑 | 挂点 | +|------|------| +| `movement_baseline` | 现网:raw 榜入库时(不变) | +| `board_select` | 渲染前;输出写入供 Agent/`llm_input` 与 wecom 共用的最终列表字段 | +| delta pad | **调用同一周去重历史**(`wecom_shown_keys`),不再独立解释 `movement_baseline` 为展示史 | +| `featured_resolve` | **先于** why 检索;池 A = 本 run `board_select`(delta 则为本 run 最终展示列表) | +| 新闻 | 所有 prepare 路径关 backfill;research SKILL 改文案规则;后处理剥「放宽」 | +| `narrative_axis` | 代码先选轴再注入 Step 1;LLM 不得另选冲突轴 | +| `wecom_shown_keys` 写回 | `replace_wecom_*` / `build_wecom_report` 之后,与最终正文列表一致 | + +--- + +## Section 2 — 各榜选条 + 今日首推改推 + +### 2.1 `board_select`(五榜共用) + +适用:`skills_trending` / `skills_hot` / `github_trending` / `github_emerging` / `github_topic`。 + +``` +输入:当日 raw 池(深,pool ≥ DAILY_BOARD_POOL_SIZE) +历史:近 DAILY_BOARD_DEDUP_DAYS 的 wecom_shown_keys[board] +输出:至多 N 条(N = 现有 wecom Top 配置) + +1. 现有整理(Skills:source 合并;GitHub:repo key) +2. 滤掉近 7 天该榜 wecom_shown_keys +3. 按原排名取前 N +4. 不足 → 继续扫深池,仍排除周历史,直到满 N 或池空 +5. 池空仍不足 → 短榜;日志 board_short:{board}:{n};不回填周内已展示条目 +``` + +分榜独立历史:Trending 出过的 skill,Hot 仍可出。 + +Post-render:将**实际写入企微的** keys 写入当日 `wecom_shown_keys`(测试断言:history ⊆ / == 渲染列表,**≠** `movement_baseline`)。 + +### 2.2 `featured_resolve` + +**触发**:本 run 拟用首推身份与**前一天** `featured_pick_key`(或等价 data 字段)相同。 +身份函数:skill → `skill_id`;github → `owner/repo`。 +含 `DAILY_FEATURED_PICK` 与自动首推;本期不豁免。无昨日文件 → 不改推。 + +**顺序(硬)**:定候选 → 再 `research`/`why_today`(禁止先写旧条目 why 再改人却不重写)。 + +**候选**: + +1. **池 A**:本 run **最终会展示**的 Skills + GitHub 榜条目(与 `wecom_shown_keys` 同源结构) +2. **池 B**:raw 深池中尚未进入本 run 展示者 + +**过滤**:近 30 天 `featured_pick_key`;排除冲突项自身。 + +**抽取**:`hash(date_str + "featured")` 可复现;测试可注入 RNG。先 A 后 B;仍空 → 保留原首推 + `featured_fallback_exhausted`。 + +**落盘**:`data.featured_pick_key`。 + +--- + +## Section 3 — 开场主题 + AI 时讯 + +### 3.1 开场主题 + +| 机制 | 强度 | 规则 | +|------|------|------| +| `narrative_axis` | **硬** | 代码 `pick_narrative_axis(used_last_N)` 从剩余枚举选取;注入 prompt;LLM 输出须等于该轴;冲突则重试 1 次,再失败则**强制覆写为代码所选轴**再落盘(保证成功标准可测) | +| theme/opening 软禁 | **软** | prompt 附近 7 天 `theme_line`/opening 摘要;禁止复述;**无** n-gram 硬匹配;**不**列入硬成功标准 | + +**叙事轴枚举**: + +`政策监管` · `模型发布` · `工具链/Agent` · `芯片算力` · `开源生态` · `应用落地` · `安全/诉讼` + +`opening` 首句证据须来自当日数据;首推改推后须跟新首推或当日主轴新闻。 + +### 3.2 AI 时讯:取消「放宽窗口」 + +目标条数 = 现网配置之和(如 `DAILY_WECOM_AI_NEWS` + tech/CN 等),文档不写死「15」。 + +1. **所有 prepare 路径**关闭「不够塞回已推/旧条」(`DAILY_NEWS_BACKFILL=0` 默认);`pushed-news-links` 过滤保留。 +2. 不够 → 深度检索补新闻(须:https link、未 pushed、可核实);**补入年龄上限** = `DAILY_AI_NEWS_HOURS`(与主窗一致),禁止借 research 变相放宽到任意旧闻。 +3. 改 research SKILL:删除「放宽至 48h 并注明」;后处理剥 `放宽窗口`/`放宽至` 前缀或丢弃。 +4. 仍不足 → 短列表 + `news_short:{n}`。 + +中文化:`daily/localize.py`(不变)。 + +--- + +## Section 4 — 配置、错误处理、测试 + +### 4.1 环境变量 + +| 变量 | 默认 | 含义 | +|------|------|------| +| `DAILY_BOARD_DEDUP_DAYS` | `7` | 读 `wecom_shown_keys` 的滚动天数 | +| `DAILY_BOARD_POOL_SIZE` | ≥50 / 与现有 skill pool 对齐 | 深池扫描深度 | +| `DAILY_FEATURED_DEDUP_DAYS` | `30` | 今日首推月去重 | +| `DAILY_THEME_BAN_DAYS` | `7` | 软禁:注入 prompt 的 theme 天数 | +| `DAILY_NARRATIVE_AXIS_DAYS` | `3` | 叙事轴互斥窗 | +| `DAILY_NEWS_BACKFILL` | `0` | `0`=禁止旧闻凑数 | +| `DAILY_NEWS_DEDUP_DAYS` | 已有 `7` | pushed-links | + +`DAILY_DELTA_PAD_LOOKBACK_DAYS` 应与 `DAILY_BOARD_DEDUP_DAYS` 对齐,且 **pad 与 board_select 共用 `wecom_shown_keys`**(窗口对齐不够,数据源必须同一)。 + +### 4.2 错误与降级 + +| 情况 | 行为 | +|------|------| +| 无 `wecom_shown_keys` 历史 | 空集合,正常满榜 | +| 周去重后深池不足 | 短榜 + `board_short` | +| 首推冲突且 A/B 空 | 保留原首推 + `featured_fallback_exhausted` | +| LLM 轴与代码轴冲突 | 覆写为代码轴 | +| 深检索仍不足时讯 | 短列表;禁止 backfill | +| history 读写失败 | 当次按空历史 + error 日志 | + +### 4.3 测试(pytest) + +1. `board_select`:假 `wecom_shown_keys` + 深池 → 无周交集;深池补满;不足短榜 +2. **回归钉死**:写回后 `wecom_shown_keys` ≠ 用 `movement_baseline` 推导的集合(构造 raw Top 与展示 Top 故意不同) +3. delta:pad 不引入近 7 日 `wecom_shown_keys` 内 key +4. `featured_resolve`:先定人再 why;A 优先 B;月未见;可注入 RNG +5. news:`BACKFILL=0`;剥「放宽*」;research 补入不超 hours 窗 +6. `pick_narrative_axis`:近 3 天互斥;落盘轴 == 代码轴 +7. 既有 delta / pushed_links / wecom 回归不挂 + +### 4.4 成功标准(硬) + +- 连续两天:**首推 key 不同**(除非 `featured_fallback_exhausted`) +- 同一榜近 7 日 `wecom_shown_keys`:**无重复 key**(池足够时) +- 时讯:无「放宽*」标注;无 backfill 已推 link +- 近 3 天 `narrative_axis`:**两两不同**(代码保证) + +软标准(不闸门):opening 读感不像连续复印。 + +--- + +## Implementation Sketch(非计划明细) + +1. `data.json` 增加 `wecom_shown_keys`;改 `load_recent_*` 数据源 +2. `board_select` + 让 delta pad 共用 +3. post-render persist shown keys +4. `featured_resolve` 时序修正 +5. news backfill off + SKILL + 剥前缀 +6. `pick_narrative_axis` + prompt 注入 +7. 测试如上 + +正式任务拆解 → `writing-plans`。 + +## Office-hours Review Notes + +- 对抗审阅质量约 4/10 → 本修订处理三大硬伤(存储拆分、列表主人、真相表)。 +- 未纳入本期(原选项 B):首推质量加权、关键短语硬匹配。 +- 已知残留:source 级「同类」周内可再现。 + +## Spec Self-Review + +- [x] `movement_baseline` 与 `wecom_shown_keys` 职责分离写死 +- [x] 单一列表主人 + full/delta 交互说明 +- [x] 历史真相表无「与/或」双源 +- [x] 轴硬 / 短语软;成功标准不含无法验证的短语匹配 +- [x] 周不足=深池、首推=A→B、新闻禁放宽 与访谈一致 diff --git a/run-scheduler.ps1 b/run-scheduler.ps1 new file mode 100644 index 0000000..b305d79 --- /dev/null +++ b/run-scheduler.ps1 @@ -0,0 +1,43 @@ +# Start the daily report scheduler (generate @ 08:50, push @ 09:00 by default) +# Usage: +# .\run-scheduler.ps1 +# .\run-scheduler.ps1 -DryRun +# .\run-scheduler.ps1 -Once + +param( + [switch]$DryRun, + [switch]$Once +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $Root + +function Import-DotEnvFile { + param([string]$Path) + if (-not (Test-Path $Path)) { return } + Get-Content $Path -Encoding UTF8 | ForEach-Object { + if ($_ -match '^\s*#' -or $_ -notmatch '=') { return } + $pair = $_ -split '=', 2 + if ($pair.Count -eq 2) { + $name = $pair[0].Trim() + $value = $pair[1].Trim().Trim('"').Trim("'") + if ($name -and $value) { + Set-Item -Path "Env:$name" -Value $value + } + } + } +} + +Import-DotEnvFile (Join-Path $Root ".env") +Import-DotEnvFile (Join-Path $Root ".env.local") + +$args = @("python", "-m", "daily", "schedule") +if ($DryRun) { $args += "--dry-run" } +if ($Once) { $args += "--once" } + +Write-Host "Starting scheduler: $($args -join ' ')" +& $args[0] $args[1..($args.Length - 1)] +if ($LASTEXITCODE -ne 0) { + throw "scheduler failed with exit code $LASTEXITCODE" +} diff --git a/skills/daily-featured-pick/SKILL.md b/skills/daily-featured-pick/SKILL.md new file mode 100644 index 0000000..1eb8f09 --- /dev/null +++ b/skills/daily-featured-pick/SKILL.md @@ -0,0 +1,87 @@ +# 早报今日首推检索 + +你是 **Skills / GitHub / AI 时讯早报** 的编辑研究员。Python 已完成榜单抓取;你负责为 **编辑指定的今日首推** 收集可核实信息,供后续趋势分析与写稿使用。 + +## 场景 + +- 触发:环境变量 `DAILY_FEATURED_PICK` 有值(如 `gstack` 或 `gstack|https://github.com/you/gstack`) +- 输出:严格 JSON,写入 `output/YYYY-MM-DD.featured.json` +- 你 **不** 写整篇早报、 **不** 改榜单顺序、 **不** 推送 + +## 输入 + +```json +{ + "query": "gstack", + "url_hint": "https://github.com/you/gstack", + "cwd": "D:\\path\\to\\workspace", + "data_matches": { + "skills": [], + "github": [] + } +} +``` + +| 字段 | 含义 | +|------|------| +| `query` | 编辑指定的关键词(skill 名 / repo 名片段) | +| `url_hint` | 可选,项目主页或仓库 URL | +| `cwd` | Cursor 工作目录,可在此检索 README / SKILL.md | +| `data_matches` | Python 已在今日 Top 榜中预匹配的条目(**优先使用其数字**) | + +## 检索顺序 + +1. **读 `data_matches`**:若 skills/github 有匹配, installs / star / repo / link **必须来自此处**,不得改写 +2. **读 `cwd` 本地仓库**:搜索 README、SKILL.md、package.json 描述,提炼「做什么 + 技术栈」 +3. **用 `url_hint`**:作为项目主页;无本地文件时可仅基于 URL 与 query 写 summary(须标注 evidence 来源) +4. **禁止编造**:未在 data_matches / 本地文件 / url_hint 出现的数字、功能、版本一律不写 + +## 输出 + +**只输出一个 JSON 对象**,不要 markdown 围栏,不要解释。 + +```json +{ + "title": "gstack", + "type": "skill|github|other", + "command": "npx skills add owner/repo/skill", + "url": "https://...", + "summary": "2–3 句中文:做什么 + 技术栈/场景", + "why_today": "中文,为什么今天主推(事实 + 一句判断)", + "evidence": ["Skills Trending 匹配 · remotion-render · 22.3K", "README: Agent 工作流 CLI"], + "tags": ["agent", "workflow"] +} +``` + +### 字段要求 + +| 字段 | 要求 | +|------|------| +| `title` | 展示名,通常与 query 或匹配条目 title/repo 短名一致 | +| `type` | `skill` = Skills 条目;`github` = 仓库;`other` = 仅关键词/URL | +| `command` | Skill:`npx skills add {source}/{title}`;GitHub:仓库 URL;other:url_hint 或 query | +| `url` | 可点击链接,来自 data_matches.link / repo url / url_hint | +| `summary` | 36–80 字中文,动词开头,说清用途 | +| `why_today` | 40–80 字,「事实/数字 + 判断」,不用空泛形容词;**不得**出现「编辑指定」「编辑首推」等内部流程用语 | +| `evidence` | 2–4 条短字符串,标明信息来源 | +| `tags` | 0–4 个英文或中文关键词 | + +### type 与 command 示例 + +- Skill 匹配:`type=skill`,`command=npx skills add vercel-labs/skills/find-skills` +- GitHub 匹配:`type=github`,`command=https://github.com/openclaw/openclaw` +- 仅关键词:`type=other`,`command` 用 url_hint + +## 写作原则 + +1. **事实优先**:why_today 每条 claim 能在 evidence 或 data_matches 中找到 +2. **数字必真**:installs、star 与 data_matches 完全一致 +3. **中文叙述**:summary / why_today 全中文;skill/repo 名保留英文 +4. **克制**:不写「值得关注」「game-changer」等空话 + +## 输出前自检 + +- [ ] 仅有 JSON,无围栏、无前后说明 +- [ ] data_matches 有数字时,summary/why_today 已引用 +- [ ] command / url 与 type 一致 +- [ ] 未编造未检索到的事实 diff --git a/tests/test_ai_news_research.py b/tests/test_ai_news_research.py new file mode 100644 index 0000000..2453a91 --- /dev/null +++ b/tests/test_ai_news_research.py @@ -0,0 +1,117 @@ +"""AI 时讯 deep-research 解析与企微格式。""" + +from __future__ import annotations + +import unittest + +from daily.format_wecom import _ai_news_lines, replace_wecom_news_sections +from daily.news.research import parse_research_response + + +class TestAiNewsResearchParse(unittest.TestCase): + def test_parse_items(self): + raw = """ + { + "items": [ + { + "title": "Apple sues OpenAI", + "link": "https://techcrunch.com/2026/07/10/apple/", + "source_name": "TechCrunch", + "desc_short": "苹果起诉 OpenAI 涉嫌窃取商业机密", + "published_fmt": "07-11 05:00" + } + ] + } + """ + items, tech = parse_research_response(raw, limit=10) + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["source_name"], "TechCrunch") + self.assertIn("苹果", items[0]["desc_short"]) + self.assertEqual(tech, []) + + def test_dedupe_links(self): + raw = """{"items": [ + {"title": "A", "link": "https://example.com/a", "source_name": "Ex", "desc_short": "一"}, + {"title": "B", "link": "https://example.com/a", "source_name": "Ex", "desc_short": "二"} + ]}""" + items, _ = parse_research_response(raw, limit=10) + self.assertEqual(len(items), 1) + + def test_parse_tech_items(self): + raw = """{"items": [ + {"title": "A", "link": "https://example.com/a", "source_name": "Ex", "desc_short": "一"} + ], "tech_items": [ + {"title": "B", "link": "https://example.com/b", "source_name": "Ex", "desc_short": "二"} + ]}""" + items, tech = parse_research_response(raw, limit=10, tech_limit=5) + self.assertEqual(len(items), 1) + self.assertEqual(len(tech), 1) + self.assertEqual(tech[0]["title"], "B") + + +class TestMergedWecomNews(unittest.TestCase): + def test_merged_block_format(self): + items = [ + { + "title": "Apple sues OpenAI", + "link": "https://techcrunch.com/x", + "source_name": "TechCrunch", + "desc_short": "苹果起诉 OpenAI", + "published_fmt": "07-11 05:00", + } + ] + lines = _ai_news_lines(items, merged=True) + self.assertIn("TechCrunch - Apple sues OpenAI", lines[0]) + self.assertIn("— 苹果起诉 OpenAI", lines[0]) + self.assertNotIn("07-11", lines[0]) + + def test_merged_with_tech_block(self): + md = """📰 **早报** + +📈 **Skills Trending Top 1** +1. skill +""" + main = [ + {"title": "Main", "link": "https://example.com/m", "source_name": "Src", "desc_short": "主条", "published_fmt": "07-11"} + ] + tech = [ + {"title": "Tech", "link": "https://example.com/t", "source_name": "Src2", "desc_short": "技术条", "published_fmt": "07-12"} + ] + out = replace_wecom_news_sections(md, ai_news=main, tech_ai_news=tech, merged=True) + self.assertIn("📰 **AI 时讯精选 Top 2**", out) + self.assertNotIn("技术类时讯", out) + self.assertNotIn("🔧", out) + self.assertNotIn("07-11", out) + self.assertNotIn("07-12", out) + self.assertIn("2. [Src2 - Tech]", out) + + def test_replace_merged_removes_split_blocks(self): + md = """📰 **早报** + +🌍 **国际 AI 时讯 Top 1** +1. [old](https://example.com/old) · `X` + +🇨🇳 **国内 AI 时讯 Top 1** +1. [old2](https://example.com/old2) · `Y` + +📈 **Skills Trending Top 1** +1. skill +""" + items = [ + { + "title": "New story", + "link": "https://example.com/new", + "source_name": "Fortune", + "desc_short": "新故事", + "published_fmt": "", + } + ] + out = replace_wecom_news_sections(md, ai_news=items, merged=True) + self.assertIn("📰 **AI 时讯精选 Top 1**", out) + self.assertNotIn("国际 AI 时讯", out) + self.assertNotIn("国内 AI 时讯", out) + self.assertIn("📈 **Skills Trending Top 1**", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_board_history.py b/tests/test_board_history.py index 1e9adfc..706966f 100644 --- a/tests/test_board_history.py +++ b/tests/test_board_history.py @@ -27,6 +27,19 @@ class ShownKeysTests(unittest.TestCase): items = [{"repo": "a/b"}, {"repo": "c/d"}] self.assertEqual(extract_shown_keys("github_trending", items), ["a/b", "c/d"]) + def test_extract_skill_keys_include_source(self): + items = [ + { + "id": "open.feishu.cn/lark-drive", + "source": "open.feishu.cn", + "title": "lark-drive", + } + ] + self.assertEqual( + extract_shown_keys("skills_trending", items), + ["open.feishu.cn/lark-drive", "open.feishu.cn"], + ) + def test_load_recent_reads_wecom_shown_not_baseline(self): with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) @@ -48,6 +61,41 @@ class ShownKeysTests(unittest.TestCase): self.assertEqual(keys["github_trending"], {"x/y"}) self.assertNotIn("a/b", keys["github_trending"]) + def test_load_recent_falls_back_to_wecom_md_when_shown_missing(self): + """旧日 data 无 wecom_shown_keys 时,从同日 wecom.md 解析实际展示 keys。""" + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) + payload = { + "data": { + "date": "2026-07-13", + "github_trending": [{"repo": "other/top"}], + } + } + (out / "2026-07-13.data.json").write_text( + json.dumps(payload, ensure_ascii=False), encoding="utf-8" + ) + (out / "2026-07-13.wecom.md").write_text( + "\n".join( + [ + "🐙 **GitHub Trending Top 2**", + "1. [vinta/awesome-python](https://github.com/vinta/awesome-python)", + "2. [react/react](https://github.com/react/react)", + "", + "🌱 **GitHub 新兴 Top 1**", + "1. [elder-plinius/T3MP3ST](https://github.com/elder-plinius/T3MP3ST)", + ] + ), + encoding="utf-8", + ) + with patch("daily.board_history.OUTPUT_DIR", out): + keys = load_recent_shown_keys("2026-07-14", lookback_days=7) + self.assertEqual( + keys["github_trending"], + {"vinta/awesome-python", "react/react"}, + ) + self.assertEqual(keys["github_emerging"], {"elder-plinius/T3MP3ST"}) + self.assertNotIn("other/top", keys["github_trending"]) + def test_merge_shown_does_not_touch_baseline(self): data = { "movement_baseline": {"github_trending": [{"repo": "raw/one"}]}, diff --git a/tests/test_board_select.py b/tests/test_board_select.py index 779e472..8255b4e 100644 --- a/tests/test_board_select.py +++ b/tests/test_board_select.py @@ -53,6 +53,39 @@ class BoardSelectTests(unittest.TestCase): ) self.assertEqual([x["id"] for x in out], ["c/d/s2"]) + def test_skill_filters_recent_by_source(self): + items = [ + {"id": "a/b/s-new", "source": "a/b", "title": "s-new", "installs": 10}, + {"id": "c/d/s2", "source": "c/d", "title": "s2", "installs": 9}, + ] + out = board_select( + board="skills_hot", + items=items, + recent_keys={"a/b"}, # source-level history + limit=10, + pool_size=50, + kind="skill", + ) + self.assertEqual([x["id"] for x in out], ["c/d/s2"]) + + def test_skill_filters_recent_skill_id_as_same_source(self): + from daily.format_wecom import expand_skill_recent_keys + + items = [ + {"id": "open.feishu.cn/lark-drive", "source": "open.feishu.cn", "title": "lark-drive", "installs": 10}, + {"id": "fresh/src/s", "source": "fresh/src", "title": "s", "installs": 9}, + ] + recent = expand_skill_recent_keys({"open.feishu.cn/lark-doc"}) + out = board_select( + board="skills_trending", + items=items, + recent_keys=recent, + limit=10, + pool_size=50, + kind="skill", + ) + self.assertEqual([x["id"] for x in out], ["fresh/src/s"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_featured_pick.py b/tests/test_featured_pick.py new file mode 100644 index 0000000..25a2453 --- /dev/null +++ b/tests/test_featured_pick.py @@ -0,0 +1,95 @@ +"""Tests for daily.featured_pick.""" + +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from daily.featured_pick import ( + apply_featured_pick, + match_in_data, + parse_featured_pick, + pick_command_from_featured, + pick_why_from_featured, + research_featured_pick, +) + + +SAMPLE_INPUT = { + "skills_trending": [ + { + "id": "foo/bar/gstack-cli", + "title": "gstack-cli", + "source": "foo/bar", + "installs": 1200, + "installs_fmt": "1.2K", + "link": "https://skills.sh/foo/bar/gstack-cli", + "description": "Agent workflow CLI", + } + ], + "skills_hot": [], + "github_trending": [ + { + "repo": "acme/gstack", + "url": "https://github.com/acme/gstack", + "total_stars_fmt": "3.2K", + "description": "GStack toolkit", + } + ], + "github_emerging": [], + "github_topic": {"topic": "llm", "repos": []}, +} + + +class ParseFeaturedPickTests(unittest.TestCase): + def test_empty(self): + with patch.dict(os.environ, {}, clear=True): + self.assertIsNone(parse_featured_pick()) + + def test_query_only(self): + with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack"}, clear=True): + self.assertEqual(parse_featured_pick(), {"query": "gstack"}) + + def test_query_with_url(self): + with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack|https://example.com"}, clear=True): + self.assertEqual( + parse_featured_pick(), + {"query": "gstack", "url_hint": "https://example.com"}, + ) + + +class MatchInDataTests(unittest.TestCase): + def test_matches_skill_and_github(self): + matches = match_in_data(SAMPLE_INPUT, "gstack") + self.assertEqual(len(matches["skills"]), 1) + self.assertEqual(matches["skills"][0]["title"], "gstack-cli") + self.assertEqual(len(matches["github"]), 1) + self.assertEqual(matches["github"][0]["repo"], "acme/gstack") + + +class FeaturedPickWorkflowTests(unittest.TestCase): + def test_fallback_without_llm(self): + llm_input = dict(SAMPLE_INPUT) + with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack"}, clear=True): + with patch("daily.featured_pick.has_llm_configured", return_value=False): + featured = research_featured_pick(llm_input, date_str="2026-07-03") + self.assertIsNotNone(featured) + assert featured is not None + self.assertEqual(featured["type"], "skill") + self.assertIn("npx skills add foo/bar/gstack-cli", featured["command"]) + self.assertTrue(featured["why_today"]) + + def test_apply_featured_pick_mutates_input(self): + llm_input = dict(SAMPLE_INPUT) + with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack"}, clear=True): + with patch("daily.featured_pick.has_llm_configured", return_value=False): + featured = apply_featured_pick(llm_input, date_str="2026-07-03") + self.assertIsNotNone(featured) + self.assertIn("featured_pick", llm_input) + self.assertEqual(pick_command_from_featured(featured), llm_input["featured_pick"]["command"]) + self.assertEqual(pick_why_from_featured(featured), llm_input["featured_pick"]["why_today"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_featured_resolve.py b/tests/test_featured_resolve.py index e51ea65..716573e 100644 --- a/tests/test_featured_resolve.py +++ b/tests/test_featured_resolve.py @@ -1,10 +1,19 @@ # tests/test_featured_resolve.py from __future__ import annotations +import json import random +import tempfile import unittest +from pathlib import Path +from unittest.mock import patch -from daily.featured_pick import featured_identity_key, featured_resolve +from daily.featured_pick import ( + featured_identity_key, + featured_resolve, + load_recent_featured_keys, + load_yesterday_featured_key, +) class FeaturedResolveTests(unittest.TestCase): @@ -78,6 +87,46 @@ class FeaturedResolveTests(unittest.TestCase): "foo/bar", ) + def test_load_yesterday_falls_back_to_featured_pick(self): + """缺 featured_pick_key 时从 featured_pick.url 推导身份,避免连日重复首推。""" + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) + payload = { + "data": { + "date": "2026-07-13", + "featured_pick": { + "type": "github", + "title": "headroom", + "url": "https://github.com/headroomlabs-ai/headroom", + }, + } + } + (out / "2026-07-13.data.json").write_text( + json.dumps(payload, ensure_ascii=False), encoding="utf-8" + ) + with patch("daily.featured_pick.OUTPUT_DIR", out): + key = load_yesterday_featured_key("2026-07-14") + self.assertEqual(key, "headroomlabs-ai/headroom") + + def test_load_recent_falls_back_to_featured_pick(self): + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) + payload = { + "data": { + "date": "2026-07-13", + "featured_pick": { + "type": "github", + "url": "https://github.com/headroomlabs-ai/headroom", + }, + } + } + (out / "2026-07-13.data.json").write_text( + json.dumps(payload, ensure_ascii=False), encoding="utf-8" + ) + with patch("daily.featured_pick.OUTPUT_DIR", out): + keys = load_recent_featured_keys("2026-07-14", days=7) + self.assertIn("headroomlabs-ai/headroom", keys) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_github_search.py b/tests/test_github_search.py new file mode 100644 index 0000000..476242f --- /dev/null +++ b/tests/test_github_search.py @@ -0,0 +1,71 @@ +"""Tests for GitHub Search pagination / deep pool.""" + +from __future__ import annotations + +import os +import unittest +from unittest.mock import MagicMock, patch + + +class SearchGithubReposPaginationTests(unittest.TestCase): + def test_search_paginates_beyond_first_page_of_30(self): + from daily.github.search import search_github_repos + + def make_items(start: int, n: int) -> list[dict]: + return [ + { + "full_name": f"org/repo{i}", + "html_url": f"https://github.com/org/repo{i}", + "description": f"desc {i}", + "language": "Python", + "stargazers_count": 1000 - i, + "created_at": "2026-01-01T00:00:00Z", + } + for i in range(start, start + n) + ] + + responses = [ + MagicMock(status_code=200, json=lambda: {"items": make_items(1, 100)}), + MagicMock(status_code=200, json=lambda: {"items": make_items(101, 50)}), + ] + client = MagicMock() + client.__enter__.return_value = client + client.__exit__.return_value = False + client.get.side_effect = responses + + with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}, clear=False): + with patch("daily.github.search.httpx.Client", return_value=client): + with patch("daily.github.search.github_token", return_value="test-token"): + repos = search_github_repos("stars:>50", 120, require_token=True) + + self.assertEqual(len(repos), 120) + self.assertEqual(repos[0]["repo"], "org/repo1") + self.assertEqual(repos[119]["repo"], "org/repo120") + self.assertEqual(client.get.call_count, 2) + first_params = client.get.call_args_list[0].kwargs["params"] + self.assertEqual(first_params["per_page"], 100) + self.assertEqual(first_params["page"], 1) + + +class GithubBoardDeepPoolTests(unittest.TestCase): + def test_board_select_fills_ten_when_deep_pool_has_fresh_repos(self): + from daily.board_select import board_select + + recent = {f"old/r{i}" for i in range(1, 33)} + items = [{"repo": f"old/r{i}"} for i in range(1, 31)] + [ + {"repo": f"fresh/r{i}"} for i in range(1, 20) + ] + selected = board_select( + board="github_trending", + items=items, + recent_keys=recent, + limit=10, + pool_size=100, + kind="github", + ) + self.assertEqual(len(selected), 10) + self.assertTrue(all(r["repo"].startswith("fresh/") for r in selected)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_news_fetch_window.py b/tests/test_news_fetch_window.py new file mode 100644 index 0000000..7eb73b9 --- /dev/null +++ b/tests/test_news_fetch_window.py @@ -0,0 +1,156 @@ +"""Tests for news time window filtering.""" + +from __future__ import annotations + +import os +import unittest +from datetime import datetime, timezone, timedelta +from unittest.mock import patch +from zoneinfo import ZoneInfo + +from daily.news.fetch import _cutoff_datetime, _parse_datetime, _within_window + + +class NewsWindowTests(unittest.TestCase): + def test_cutoff_floor_today_excludes_yesterday_even_within_24h(self): + tz = ZoneInfo("Asia/Shanghai") + # 2026-07-09 09:00 CST = 2026-07-09 01:00 UTC + fixed = datetime(2026, 7, 9, 1, 0, tzinfo=timezone.utc) + with patch("daily.news.fetch._now_utc", return_value=fixed): + with patch.dict(os.environ, {"DAILY_AI_NEWS_HOURS": "24"}, clear=False): + cutoff = _cutoff_datetime(floor_today=True) + start_today_cst = datetime(2026, 7, 9, 0, 0, tzinfo=tz).astimezone(timezone.utc) + self.assertEqual(cutoff, start_today_cst) + yesterday = datetime(2026, 7, 8, 20, 0, tzinfo=tz).astimezone(timezone.utc) + self.assertFalse(_within_window({"published": yesterday.isoformat()}, cutoff)) + + def test_cutoff_rolling_only_includes_last_24h(self): + fixed = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc) + with patch("daily.news.fetch._now_utc", return_value=fixed): + with patch.dict(os.environ, {"DAILY_AI_NEWS_HOURS": "24"}, clear=False): + cutoff = _cutoff_datetime(floor_today=False) + self.assertEqual(cutoff, fixed - timedelta(hours=24)) + + def test_within_window_rejects_missing_datetime(self): + cutoff = datetime(2026, 7, 9, 0, 0, tzinfo=timezone.utc) + self.assertFalse(_within_window({"title": "x", "link": "https://a.com"}, cutoff)) + + def test_parse_date_only_uses_local_noon(self): + with patch.dict(os.environ, {"DAILY_AI_NEWS_TZ": "Asia/Shanghai"}, clear=False): + dt = _parse_datetime("2026-07-09") + self.assertIsNotNone(dt) + assert dt is not None + local = dt.astimezone(ZoneInfo("Asia/Shanghai")) + self.assertEqual(local.hour, 12) + + +class NewsFormatTests(unittest.TestCase): + def test_ai_news_lines_use_desc_as_link_text(self): + from daily.format_wecom import _ai_news_lines + + lines = _ai_news_lines( + [ + { + "title": "English Title", + "link": "https://example.com/a", + "source_name": "Src", + "published_fmt": "07-11", + "desc_short": "中文摘要一句", + } + ] + ) + self.assertEqual(len(lines), 1) + self.assertIn("[中文摘要一句](https://example.com/a)", lines[0]) + self.assertNotIn("English Title", lines[0]) + self.assertNotIn("> ", lines[0]) + + def test_ai_news_lines_fallback_to_title(self): + from daily.format_wecom import _ai_news_lines + + lines = _ai_news_lines( + [ + { + "title": "仅标题", + "link": "https://example.com/b", + "source_name": "Src", + "published_fmt": "", + "desc_short": "", + } + ] + ) + self.assertIn("[仅标题](https://example.com/b)", lines[0]) + + +class NewsSummaryTests(unittest.TestCase): + def test_brief_news_summary_no_ellipsis(self): + from daily.news.fetch import brief_news_summary + + text = ( + "Meta told Dylan Byers, of Puck News, that the company removed " + "the controversial AI feature after user backlash on Instagram." + ) + out = brief_news_summary(text, limit=72) + self.assertNotIn("...", out) + self.assertLessEqual(len(out), 72) + self.assertTrue(out.startswith("Meta told")) + + def test_brief_news_summary_filters_junk(self): + from daily.news.fetch import brief_news_summary + + self.assertEqual(brief_news_summary("点击查看原文>"), "") + self.assertEqual(brief_news_summary("Article URL: https://example.com"), "") + + def test_sync_wecom_news_rows_after_localize(self): + from daily.news.fetch import _to_wecom_news_row, sync_wecom_news_rows + + row = _to_wecom_news_row( + { + "title": "t", + "link": "https://a.com/x", + "source_name": "s", + "published_fmt": "07-11", + "summary": "Short english stub that was truncated early...", + } + ) + flat = [ + { + "link": "https://a.com/x", + "summary": "苹果指控 OpenAI 窃取硬件商业机密,诉讼称 misconduct 涉及多名前员工。", + } + ] + sync_wecom_news_rows([row], flat) + self.assertNotIn("...", row["desc_short"]) + self.assertIn("苹果", row["desc_short"]) + + + def test_finalize_wecom_news_forces_chinese(self): + from daily.news.fetch import finalize_wecom_news_items + + items = [ + { + "link": "https://a.com/1", + "desc_short": "Meta removed the feature after backlash.", + "summary_plain": "Meta removed the feature after backlash.", + } + ] + with patch( + "daily.localize.localize_brief_descriptions", + return_value={"wecom-news:https://a.com/1": "Meta 在舆论压力下移除了该功能"}, + ): + finalize_wecom_news_items(items, force_chinese=True) + self.assertIn("Meta", items[0]["desc_short"]) + self.assertNotIn("backlash", items[0]["desc_short"]) + + +class NewsPickTests(unittest.TestCase): + def test_pick_and_backfill_to_limit(self): + from daily.news.fetch import _fill_picked_to_limit, _pick_news_items + + flat = [ + {"link": f"https://a.com/{i}", "title": f"t{i}", "category_id": "media", "summary": "s"} + for i in range(12) + ] + picked = _pick_news_items(flat, 10, ("media",)) + self.assertEqual(len(picked), 10) + picked = _fill_picked_to_limit(picked[:3], [flat], 10) + self.assertEqual(len(picked), 10) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..34ddbd4 --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,118 @@ +"""Tests for daily.scheduler.""" + +from __future__ import annotations + +import unittest +from datetime import datetime +from zoneinfo import ZoneInfo + +from daily.scheduler import ( + ClockTime, + SchedulerState, + next_occurrence_after, + parse_hhmm, + plan_next_action, +) + + +class ParseHhmmTests(unittest.TestCase): + def test_parse(self): + t = parse_hhmm("08:50") + self.assertEqual((t.hour, t.minute), (8, 50)) + + def test_invalid(self): + with self.assertRaises(ValueError): + parse_hhmm("25:00") + + +class PlanNextActionTests(unittest.TestCase): + def setUp(self) -> None: + self.tz = ZoneInfo("Asia/Shanghai") + self.gen = ClockTime(8, 50) + self.push = ClockTime(9, 0) + + def test_before_generate_waits_for_generate(self): + now = datetime(2026, 7, 9, 8, 30, tzinfo=self.tz) + run_at, action = plan_next_action( + now=now, + tz=self.tz, + state=SchedulerState(), + generate_at=self.gen, + push_at=self.push, + ) + self.assertEqual(action, "generate") + self.assertEqual(run_at.hour, 8) + self.assertEqual(run_at.minute, 50) + + def test_after_generate_before_push_waits_for_push(self): + now = datetime(2026, 7, 9, 8, 55, tzinfo=self.tz) + state = SchedulerState(last_generate_date="2026-07-09") + run_at, action = plan_next_action( + now=now, + tz=self.tz, + state=state, + generate_at=self.gen, + push_at=self.push, + ) + self.assertEqual(action, "push") + self.assertEqual(run_at.hour, 9) + + def test_catch_up_generate_when_started_late(self): + now = datetime(2026, 7, 9, 8, 55, tzinfo=self.tz) + run_at, action = plan_next_action( + now=now, + tz=self.tz, + state=SchedulerState(), + generate_at=self.gen, + push_at=self.push, + ) + self.assertEqual(action, "generate") + self.assertEqual(run_at, now) + + def test_next_day_after_both_done(self): + now = datetime(2026, 7, 9, 10, 0, tzinfo=self.tz) + state = SchedulerState(last_generate_date="2026-07-09", last_push_date="2026-07-09") + run_at, action = plan_next_action( + now=now, + tz=self.tz, + state=state, + generate_at=self.gen, + push_at=self.push, + ) + self.assertEqual(action, "generate") + self.assertEqual(run_at.date().isoformat(), "2026-07-10") + + def test_evening_start_waits_for_tomorrow_generate(self): + now = datetime(2026, 7, 9, 20, 35, tzinfo=self.tz) + run_at, action = plan_next_action( + now=now, + tz=self.tz, + state=SchedulerState(), + generate_at=self.gen, + push_at=self.push, + ) + self.assertEqual(action, "generate") + self.assertEqual(run_at.date().isoformat(), "2026-07-10") + self.assertEqual((run_at.hour, run_at.minute), (8, 50)) + + def test_catch_up_push_when_generate_done(self): + now = datetime(2026, 7, 9, 20, 35, tzinfo=self.tz) + state = SchedulerState(last_generate_date="2026-07-09") + run_at, action = plan_next_action( + now=now, + tz=self.tz, + state=state, + generate_at=self.gen, + push_at=self.push, + ) + self.assertEqual(action, "push") + self.assertEqual(run_at, now) + + +class NextOccurrenceTests(unittest.TestCase): + def test_tomorrow_when_past(self): + tz = ZoneInfo("Asia/Shanghai") + now = datetime(2026, 7, 9, 10, 0, tzinfo=tz) + nxt = next_occurrence_after(ClockTime(8, 50), tz, now) + self.assertEqual(nxt.date().isoformat(), "2026-07-10") + self.assertEqual((nxt.hour, nxt.minute), (8, 50)) diff --git a/tests/test_wecom_delta.py b/tests/test_wecom_delta.py index d67a83f..6a4067e 100644 --- a/tests/test_wecom_delta.py +++ b/tests/test_wecom_delta.py @@ -229,6 +229,192 @@ class DeltaFormatTests(unittest.TestCase): self.assertIn("Skills Trending Top 10", text) self.assertNotIn("Skills Trending 变化", text) + def test_delta_pad_keeps_large_clusters_merged_and_fills_limit(self): + """补榜按 source 合并态取条,大 cluster 不得撑爆 flat 预算导致短榜。""" + from daily.format_wecom import build_skills_delta_sections + + def cluster(source: str, n: int, installs: int) -> dict: + titles = [f"t{i}" for i in range(n)] + return { + "id": f"{source}/{titles[0]}", + "title": titles[0], + "source": source, + "installs": installs, + "installs_fmt": str(installs), + "cluster": True, + "cluster_count": n, + "cluster_skills": titles, + "cluster_titles": ", ".join(titles[:4]) + "…", + "link": f"https://skills.sh/{source}/{titles[0]}", + "description": f"{source} cluster", + } + + full = [cluster(f"big{i}/pkg", 20, 1000 - i) for i in range(1, 5)] + [ + { + "id": f"other{n}/pkg/skill", + "title": "skill", + "source": f"other{n}/pkg", + "installs": 50 - n, + "installs_fmt": str(50 - n), + "link": f"https://skills.sh/other{n}/pkg/skill", + "description": f"other {n}", + } + for n in range(1, 12) + ] + with patch("daily.format_wecom.localize_brief_descriptions", return_value={}): + with patch("daily.format_wecom.needs_chinese", return_value=False): + text = build_skills_delta_sections( + [], + [], + trending_full=full, + hot_full=[], + trending_limit=10, + pad=True, + ) + self.assertIn("Skills Trending Top 10", text) + self.assertIn("20 skills", text) + self.assertIn("other6/pkg", text) + + def test_delta_pad_recent_blocks_same_source_not_just_primary_id(self): + """周去重按 source:换同仓另一个 skill id 不得再上榜。""" + from daily.format_wecom import build_skills_delta_sections + + titles = [f"t{i}" for i in range(20)] + full = [ + { + "id": f"big/pkg/{titles[0]}", + "title": titles[0], + "source": "big/pkg", + "installs": 999, + "installs_fmt": "999", + "cluster": True, + "cluster_count": 20, + "cluster_skills": titles, + "cluster_titles": ", ".join(titles[:4]) + "…", + "link": f"https://skills.sh/big/pkg/{titles[0]}", + "description": "big cluster", + }, + *[ + { + "id": f"other{n}/pkg/skill", + "title": "skill", + "source": f"other{n}/pkg", + "installs": 50 - n, + "installs_fmt": str(50 - n), + "link": f"https://skills.sh/other{n}/pkg/skill", + "description": f"other {n}", + } + for n in range(1, 12) + ], + ] + # 昨日展示的是同 source 另一 skill id(非今日 primary) + recent = {f"big/pkg/{titles[5]}"} + with patch("daily.format_wecom.localize_brief_descriptions", return_value={}): + with patch("daily.format_wecom.needs_chinese", return_value=False): + text = build_skills_delta_sections( + [], + [], + trending_full=full, + hot_full=[], + trending_limit=10, + pad=True, + recent_trending=recent, + ) + self.assertIn("Skills Trending Top 10", text) + self.assertNotIn("big/pkg", text) + self.assertIn("other1/pkg", text) + + def test_delta_pad_hot_recent_unions_trending_history_by_source(self): + """Skills Hot 周去重合并 Trending 历史:隔日换榜也不能同 source 再出现。""" + from daily.format_wecom import build_skills_delta_sections + + hot_full = [ + { + "id": "101-skills/skills/ai-music", + "title": "ai-music", + "source": "101-skills/skills", + "installs": 200, + "installs_fmt": "200", + "link": "https://skills.sh/101-skills/skills/ai-music", + "description": "hot candidate", + }, + { + "id": "fresh/src/skill", + "title": "skill", + "source": "fresh/src", + "installs": 100, + "installs_fmt": "100", + "link": "https://skills.sh/fresh/src/skill", + "description": "fresh", + }, + ] + with patch("daily.format_wecom.localize_brief_descriptions", return_value={}): + with patch("daily.format_wecom.needs_chinese", return_value=False): + text = build_skills_delta_sections( + [], + [], + trending_full=[], + hot_full=hot_full, + trending_limit=10, + hot_limit=10, + pad=True, + recent_trending={"101-skills/skills/ai-video-generation"}, + recent_hot=set(), + ) + self.assertIn("Skills Hot Top 1", text) + self.assertIn("fresh/src", text) + self.assertNotIn("101-skills", text) + + def test_delta_pad_hot_excludes_trending_by_source(self): + """同日 Hot 补榜按 source 避开 Trending,而非展开全部 cluster skill id。""" + from daily.format_wecom import build_skills_delta_sections + + trending_full = [ + { + "id": "same/src/a", + "title": "a", + "source": "same/src", + "installs": 100, + "installs_fmt": "100", + "link": "https://skills.sh/same/src/a", + "description": "trending item", + } + ] + hot_full = [ + { + "id": "same/src/b", + "title": "b", + "source": "same/src", + "installs": 90, + "installs_fmt": "90", + "link": "https://skills.sh/same/src/b", + "description": "hot twin", + }, + { + "id": "fresh/src/skill", + "title": "skill", + "source": "fresh/src", + "installs": 80, + "installs_fmt": "80", + "link": "https://skills.sh/fresh/src/skill", + "description": "fresh hot", + }, + ] + with patch("daily.format_wecom.localize_brief_descriptions", return_value={}): + with patch("daily.format_wecom.needs_chinese", return_value=False): + text = build_skills_delta_sections( + [], + [], + trending_full=trending_full, + hot_full=hot_full, + trending_limit=10, + hot_limit=10, + pad=True, + ) + self.assertIn("Skills Hot Top 1", text) + self.assertIn("fresh/src", text) + self.assertNotIn("same/src/b", text) + def test_delta_pad_uses_large_pool_when_recent_excludes_top(self): from daily.format_wecom import build_skills_delta_sections @@ -306,6 +492,46 @@ class DeltaFormatTests(unittest.TestCase): self.assertIn("GitHub Trending Top 10", text) self.assertNotIn("GitHub Trending 变化", text) + def test_delta_pad_github_unions_recent_across_boards(self): + """GitHub 三榜共用周去重:Trending 出过的 repo,新兴/Topic 不得再出。""" + from daily.format_wecom import build_github_delta_sections + + movement = {"github_trending_moves": [], "github_emerging_moves": [], "github_topic_moves": []} + shared = { + "repo": "seen/repo", + "url": "https://github.com/seen/repo", + "language": "Go", + "stars_today_fmt": "100", + "total_stars_fmt": "1K", + "created_at": "2026-07-01", + "description": "already shown", + "desc_short": "already shown", + } + fresh = { + "repo": "fresh/repo", + "url": "https://github.com/fresh/repo", + "language": "Go", + "stars_today_fmt": "90", + "total_stars_fmt": "900", + "created_at": "2026-07-02", + "description": "fresh", + "desc_short": "fresh", + } + with patch("daily.format_wecom.localize_brief_descriptions", return_value={}): + text = build_github_delta_sections( + movement, + topic_name="llm", + github_trending=[], + github_emerging=[shared, fresh], + github_topic=[shared], + emerging_limit=5, + topic_limit=5, + pad=True, + recent_board_keys={"github_trending": {"seen/repo"}}, + ) + self.assertIn("fresh/repo", text) + self.assertNotIn("seen/repo", text) + def test_delta_pad_skips_recent_skills(self): from daily.format_wecom import build_skills_delta_sections @@ -320,6 +546,16 @@ class DeltaFormatTests(unittest.TestCase): "description": f"skill {i}", } for i in range(4, 6) + ] + [ + { + "id": "fresh/src/skill", + "title": "skill", + "source": "fresh/src", + "installs": 50, + "installs_fmt": "50", + "link": "https://skills.sh/fresh/src/skill", + "description": "fresh skill", + } ] with patch("daily.format_wecom.localize_brief_descriptions", return_value={}): with patch("daily.format_wecom.needs_chinese", return_value=False): @@ -330,10 +566,12 @@ class DeltaFormatTests(unittest.TestCase): hot_full=[], trending_limit=10, pad=True, + # 同仓历史 skill id → 整仓 source 去重;仅保留其它 source recent_trending={f"x/y/s{i}" for i in range(1, 4)}, ) self.assertIn("Skills Trending Top 1", text) - self.assertIn("2 skills", text) + self.assertIn("fresh/src", text) + self.assertNotIn("x/y", text) def test_delta_pad_skips_recent_github(self): from daily.format_wecom import build_github_delta_sections @@ -363,6 +601,45 @@ class DeltaFormatTests(unittest.TestCase): self.assertIn("org/r4", text) self.assertNotIn("org/r1", text) + def test_delta_pad_skips_recent_github_moves(self): + """异动新入榜若昨日企微已展示,pad 时仍应排除(不只滤补榜)。""" + from daily.format_wecom import build_github_delta_sections + + movement = { + "github_trending_moves": [ + { + "repo": "vinta/awesome-python", + "url": "https://github.com/vinta/awesome-python", + "language": "Python", + "total_stars_fmt": "308K", + "description": "list", + } + ], + "github_emerging_moves": [], + "github_topic_moves": [], + } + full = [ + { + "repo": "fresh/repo", + "url": "https://github.com/fresh/repo", + "language": "Go", + "total_stars_fmt": "1K", + "description": "fresh", + "desc_short": "fresh", + } + ] + with patch("daily.format_wecom.localize_brief_descriptions", return_value={}): + text = build_github_delta_sections( + movement, + topic_name="llm", + github_trending=full, + trending_limit=10, + pad=True, + recent_board_keys={"github_trending": {"vinta/awesome-python"}}, + ) + self.assertIn("fresh/repo", text) + self.assertNotIn("vinta/awesome-python", text) + def test_load_recent_board_keys_from_data_json(self): import json import tempfile