feat: 早报系统重构与功能增强

- 新增常驻调度器 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:12:00 +08:00
parent 6192dd4e2a
commit 6ea2a4e4c6
35 changed files with 4389 additions and 359 deletions

View File

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

202
README.md
View File

@@ -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
# 编辑 .envWECOM_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`。复杂任务可能需要 110 分钟流式消息会显示「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 缓存。
---
## 注意事项

View File

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

View File

@@ -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,16 +16,32 @@ 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 "")
candidates = [str(item.get("repo") or "")]
for key in candidates:
if not key or key in seen:
continue
seen.add(key)
@@ -32,12 +49,86 @@ def extract_shown_keys(board: str, items: list[dict[str, Any]]) -> list[str]:
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

View File

@@ -24,19 +24,21 @@ 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:
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:

156
daily/bridge_manager.py Normal file
View File

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

View File

@@ -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:

View File

@@ -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:

View File

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

View File

@@ -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))
# 周去重后顶刊 stickyHTML/~30 条不够补满;深池默认 100Search 已分页)
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",

View File

@@ -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:
while len(repos) < target:
resp = client.get(
"https://api.github.com/search/repositories",
params={
"q": query,
"sort": sort,
"order": "desc",
"per_page": min(max(limit, 1), 30),
"per_page": per_page,
"page": page,
},
)
if resp.status_code != 200:
logger.warning("GitHub Search 失败 (%s): %s", resp.status_code, query[:80])
return []
logger.warning(
"GitHub Search 失败 (%s page=%s): %s",
resp.status_code,
page,
query[:80],
)
break
items = resp.json().get("items") or []
except Exception as exc:
logger.warning("GitHub Search 异常: %s", exc)
return []
repos: list[dict[str, Any]] = []
if not items:
break
for item in items:
full_name = item.get("full_name") or ""
if not full_name:
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) >= limit:
if len(repos) >= target:
break
return repos
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 repos[:target]
return repos[:target]
def _date_days_ago(days: int) -> str:

View File

@@ -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"))

View File

@@ -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",
]

View File

@@ -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"),

View File

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

View File

@@ -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(

284
daily/scheduler.py Normal file
View File

@@ -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())

View File

@@ -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<source>[^"]+)","skillId":"(?P<skill_id>[^"]+)",'
r'"name":"(?P<name>[^"]+)","installs":(?P<installs>\d+)'

View File

@@ -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 idsource/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"

View File

@@ -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(",、;: ,.;")

View File

@@ -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`)下,以下区块**保持**
- opening23 句,首句含具体证据)
- headline / 今日主题
- 今日信号35 条)
- 今日首推
- 国际 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 文笔问题。
- 明确否决静态页、今日一装、轮换、锚点后,方案边界清晰,实现可分期。
- 推送闸门必须**把新闻算进去**,否则静默日会被新闻绕过。

File diff suppressed because it is too large Load Diff

View File

@@ -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`(默认 Falseenv `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/ybaseline 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: Task12
- 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 名本周已 shownboard_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 FAILgenerate 尚未写 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=selecteddelta=函数返回或并行计算最终列表)调用 `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` → idgithub → 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)`**不含**完整 whywhy 由后续 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 45: 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 不足 limitpicked 含已推BACKFILL=0 → 结果不含已推 link
...
def test_strip_relax_prefix(self):
self.assertEqual(
strip_relax_window_prefix("放宽窗口:苹果起诉 OpenAI"),
"苹果起诉 OpenAI",
)
```
- [ ] **Step 24: 实现并跑 `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 唯一列表主人 | T2T3 |
| 1.3 真相表 | T1, T3T5 落盘字段 |
| 2.1 board_select | T2 |
| 2.2 featured_resolve | T4 |
| 3.1 narrative_axis + 软 theme | T5 |
| 3.2 取消放宽 | T6 |
| 4.x 配置/降级/测试 | T1T7 |
无 TBDCommit 信息均为中文描述体。
## 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` 连续做完,设检查点
要哪个?

View File

@@ -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 的 movespad禁止二次独立选榜 |
## Explicit Non-Goals
| 项 | 状态 |
|----|------|
| 语义级 Skill「同一类」分类 | ❌ 本期不做 |
| 同日 Skills Trending ↔ Hot 互斥 | ❌ 本期不做 |
| 独立 editorial 微服务 | ❌ 不做 |
| 改写 `localize` 为脚本机翻 | ❌ 保持 LLM + 缓存 |
| 编辑指定首推豁免改推(`FEATURED_FORCE` | ❌ 本期不做 |
| 「开场关键短语」硬匹配算法 | ❌ 本期不做(仅 prompt 软约束;不进硬成功标准) |
## Recommended Approach: 管线选择器(路径 A
在现有 `daily generate` 内增加选条/裁决层,不新起进程:
```
采集 raw 榜 + 新闻
→ build movement_baselineraw Top compare_depth —— 仅供次日「新入榜」,禁止写展示历史)
→ board_select读 wecom_shown_keys 周历史;周去重 + 深池;输出当日最终展示列表)
· full直接取 board_select 结果前 N
· delta在 board_select 候选池内做 movespadpad 也只从该池/同规则深池取,不再另起一套历史)
→ featured_resolve与昨日首推相同则改推池 A = 本 run 最终展示 keys
→ research why先定人再写 why_today
→ news_select禁放宽凑数深检索补满剥「放宽」前缀
→ editorial代码选 narrative_axisprompt 附近 7 天 theme 软禁)
→ 渲染 wecom
→ 写回 wecom_shown_keys = 最终进入企微正文的榜条目 keyspost-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 路径关 backfillresearch SKILL 改文案规则;后处理剥「放宽」 |
| `narrative_axis` | 代码先选轴再注入 Step 1LLM 不得另选冲突轴 |
| `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. 现有整理Skillssource 合并GitHubrepo key
2. 滤掉近 7 天该榜 wecom_shown_keys
3. 按原排名取前 N
4. 不足 → 继续扫深池,仍排除周历史,直到满 N 或池空
5. 池空仍不足 → 短榜;日志 board_short:{board}:{n};不回填周内已展示条目
```
分榜独立历史Trending 出过的 skillHot 仍可出。
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)` 从剩余枚举选取;注入 promptLLM 输出须等于该轴;冲突则重试 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. deltapad 不引入近 7 日 `wecom_shown_keys` 内 key
4. `featured_resolve`:先定人再 whyA 优先 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、新闻禁放宽 与访谈一致

43
run-scheduler.ps1 Normal file
View File

@@ -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"
}

View File

@@ -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": "23 句中文:做什么 + 技术栈/场景",
"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仓库 URLotherurl_hint 或 query |
| `url` | 可点击链接,来自 data_matches.link / repo url / url_hint |
| `summary` | 3680 字中文,动词开头,说清用途 |
| `why_today` | 4080 字,「事实/数字 + 判断」,不用空泛形容词;**不得**出现「编辑指定」「编辑首推」等内部流程用语 |
| `evidence` | 24 条短字符串,标明信息来源 |
| `tags` | 04 个英文或中文关键词 |
### 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 一致
- [ ] 未编造未检索到的事实

View File

@@ -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()

View File

@@ -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"}]},

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

118
tests/test_scheduler.py Normal file
View File

@@ -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))

View File

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