- 新增常驻调度器 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>
39 KiB
企微早报 Delta 模式 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 将企微早报从全量 Top 榜复印改为 Delta 变化通知:列表仅展示新入榜、新闻 7 天 link 去重、满足推送闸门才 webhook,榜全稳且无新新闻时静默。
Architecture: 在现有 daily/delta.py movement 数据之上,新增 effective_wecom_mode(含首日 baseline fallback)、format_wecom delta 渲染、daily/news/pushed_links.py 新闻去重缓存、daily/push_gate.py 推送闸门。generate.py 串联后把闸门结果写入 output/{date}.data.json 的 meta.push_gate;webhook.py 与 run-daily.ps1 推送前读取并跳过静默日。Agent 模式仍由 Python 插入榜单区块,更新 skills/daily-agent/SKILL.md 与 delta 对齐。
Tech Stack: Python 3.13+、stdlib unittest + pytest(仓库现有 tests/test_featured_pick.py 用 unittest)、python-dotenv、httpx
Global Constraints
DAILY_WECOM_MODE合法值:full|delta;默认deltaDAILY_DELTA_BASELINE_FALLBACK合法值:full|empty;默认fullDAILY_SKIP_PUSH_WHEN_SILENT默认1(真)DAILY_FORCE_PUSH=1时忽略静默,强制推送DAILY_NEWS_DEDUP_DAYS默认7DAILY_AI_NEWS_HOURS默认改为24(.env.example)- 新闻去重 cache 路径:
CACHE_DIR / "pushed-news-links.json"(即.cache/pushed-news-links.json,已在.gitignore) - 静默日:仍写
output/{date}.md、output/{date}.wecom.md、output/{date}.data.json,仅跳过 webhook - 跨榜去重:同一
skill_id在 Trending/Hot moves 均出现时,只在 Trending 区块展示一条,badge为Trending #4 · Hot #2 - 不做:静态页、今日一装、轮换版面、榜首锚点
- Classic 与 Agent 模式共用
format_wecomdelta 路径
File Structure
| 文件 | 职责 |
|---|---|
daily/config.py |
env_bool、wecom_mode()、news_dedup_days()、skip_push_when_silent()、delta_baseline_fallback()、force_push() |
daily/news/pushed_links.py |
新建 — 已推送新闻 link 缓存读写与过滤 |
daily/delta.py |
effective_wecom_mode()、partition_skill_moves_for_wecom() 跨榜去重 |
daily/push_gate.py |
新建 — PushGateResult、evaluate_push_gate() |
daily/format_wecom.py |
delta 列表渲染、replace_wecom_board_sections() 替换 Skills+GitHub |
daily/generate.py |
串联 effective mode、新闻去重、push gate、写 meta |
daily/webhook.py |
推送前读 meta.push_gate |
daily/report_data.py |
llm_input 增加 wecom_mode、effective_wecom_mode、push_gate |
skills/daily-agent/SKILL.md |
delta 模式 Agent 规则 |
.env.example |
新 env 文档 |
run-daily.ps1 |
静默日跳过 push |
tests/test_wecom_delta.py |
新建 — 单元测试 |
Task 1: Config helpers
Files:
- Modify:
daily/config.py(文件末尾追加) - Test:
tests/test_wecom_delta.py
Interfaces:
-
Consumes: 无
-
Produces:
env_bool(key: str, default: bool) -> boolwecom_mode() -> Literal["full", "delta"]news_dedup_days() -> intskip_push_when_silent() -> booldelta_baseline_fallback() -> Literal["full", "empty"]force_push() -> bool
-
Step 1: Write the failing test
# tests/test_wecom_delta.py
"""Tests for WeCom delta mode."""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from daily.config import (
delta_baseline_fallback,
env_bool,
force_push,
news_dedup_days,
skip_push_when_silent,
wecom_mode,
)
class ConfigHelpersTests(unittest.TestCase):
def test_wecom_mode_defaults_delta(self):
with patch.dict(os.environ, {}, clear=True):
self.assertEqual(wecom_mode(), "delta")
def test_wecom_mode_full(self):
with patch.dict(os.environ, {"DAILY_WECOM_MODE": "full"}, clear=True):
self.assertEqual(wecom_mode(), "full")
def test_env_bool_truthy(self):
with patch.dict(os.environ, {"DAILY_FORCE_PUSH": "1"}, clear=True):
self.assertTrue(env_bool("DAILY_FORCE_PUSH", False))
def test_skip_push_when_silent_default(self):
with patch.dict(os.environ, {}, clear=True):
self.assertTrue(skip_push_when_silent())
def test_news_dedup_days_default(self):
with patch.dict(os.environ, {}, clear=True):
self.assertEqual(news_dedup_days(), 7)
def test_delta_baseline_fallback_default(self):
with patch.dict(os.environ, {}, clear=True):
self.assertEqual(delta_baseline_fallback(), "full")
- Step 2: Run test to verify it fails
Run: python -m pytest tests/test_wecom_delta.py::ConfigHelpersTests -v
Expected: FAIL with ImportError or cannot import name 'wecom_mode'
- Step 3: Write minimal implementation
在 daily/config.py 末尾追加:
def env_bool(key: str, default: bool) -> bool:
raw = env(key)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def wecom_mode() -> str:
raw = (env("DAILY_WECOM_MODE") or "delta").strip().lower()
return raw if raw in {"full", "delta"} else "delta"
def news_dedup_days() -> int:
return max(1, env_int("DAILY_NEWS_DEDUP_DAYS", 7))
def skip_push_when_silent() -> bool:
return env_bool("DAILY_SKIP_PUSH_WHEN_SILENT", True)
def delta_baseline_fallback() -> str:
raw = (env("DAILY_DELTA_BASELINE_FALLBACK") or "full").strip().lower()
return raw if raw in {"full", "empty"} else "full"
def force_push() -> bool:
return env_bool("DAILY_FORCE_PUSH", False)
- Step 4: Run test to verify it passes
Run: python -m pytest tests/test_wecom_delta.py::ConfigHelpersTests -v
Expected: PASS(6 passed)
- Step 5: Commit
git add daily/config.py tests/test_wecom_delta.py
git commit -m "feat: 新增企微 Delta 模式相关配置读取函数"
Task 2: News pushed-link dedup cache
Files:
- Create:
daily/news/pushed_links.py - Modify:
daily/news/fetch.py(prepare_wecom_news_items/prepare_wecom_cn_news_items末尾调用过滤) - Test:
tests/test_wecom_delta.py
Interfaces:
-
Consumes:
daily.config.CACHE_DIR,daily.config.news_dedup_days(),daily.news.fetch._normalize_link -
Produces:
load_pushed_links() -> dict[str, list[str]]#{"dates": {"2026-07-08": ["https://..."]}}filter_unpushed_items(items: list[dict], *, date_str: str) -> list[dict]record_pushed_links(date_str: str, links: list[str]) -> None
-
Step 1: Write the failing test
在 tests/test_wecom_delta.py 追加:
import json
import tempfile
from pathlib import Path
from unittest.mock import patch
from daily.news.pushed_links import filter_unpushed_items, record_pushed_links
class NewsPushedLinksTests(unittest.TestCase):
def test_filter_and_record_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
cache = Path(tmp) / "pushed-news-links.json"
items = [
{"title": "A", "link": "https://example.com/a?utm_source=x"},
{"title": "B", "link": "https://example.com/b"},
]
with patch("daily.news.pushed_links._cache_path", return_value=cache):
with patch("daily.news.pushed_links.news_dedup_days", return_value=7):
record_pushed_links("2026-07-08", ["https://example.com/a"])
out = filter_unpushed_items(items, date_str="2026-07-09")
self.assertEqual(len(out), 1)
self.assertEqual(out[0]["link"], "https://example.com/b")
- Step 2: Run test to verify it fails
Run: python -m pytest tests/test_wecom_delta.py::NewsPushedLinksTests -v
Expected: FAIL ModuleNotFoundError: daily.news.pushed_links
- Step 3: Write minimal implementation
创建 daily/news/pushed_links.py:
"""已推送企微早报的新闻 link 去重缓存。"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from daily.config import CACHE_DIR, news_dedup_days
from daily.news.fetch import _normalize_link
def _cache_path() -> Path:
return CACHE_DIR / "pushed-news-links.json"
def _load_raw() -> dict[str, Any]:
path = _cache_path()
if not path.exists():
return {"dates": {}}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {"dates": {}}
if not isinstance(data.get("dates"), dict):
return {"dates": {}}
return data
def _save_raw(data: dict[str, Any]) -> None:
path = _cache_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def _prune(data: dict[str, Any], *, keep_days: int) -> None:
dates: dict[str, list[str]] = data.setdefault("dates", {})
try:
anchor = max(datetime.strptime(d, "%Y-%m-%d") for d in dates)
except ValueError:
return
cutoff = anchor - timedelta(days=keep_days)
for key in list(dates.keys()):
try:
if datetime.strptime(key, "%Y-%m-%d") < cutoff:
dates.pop(key, None)
except ValueError:
dates.pop(key, None)
def load_pushed_link_set() -> set[str]:
data = _load_raw()
out: set[str] = set()
for links in (data.get("dates") or {}).values():
if isinstance(links, list):
out.update(str(x) for x in links if x)
return out
def filter_unpushed_items(
items: list[dict[str, Any]],
*,
date_str: str,
) -> list[dict[str, Any]]:
seen = load_pushed_link_set()
out: list[dict[str, Any]] = []
for item in items:
link = _normalize_link(str(item.get("link") or ""))
if not link or link in seen:
continue
out.append(item)
return out
def record_pushed_links(date_str: str, links: list[str]) -> None:
data = _load_raw()
dates: dict[str, list[str]] = data.setdefault("dates", {})
normalized = []
for link in links:
clean = _normalize_link(link)
if clean:
normalized.append(clean)
dates[date_str] = sorted(set(normalized))
_prune(data, keep_days=news_dedup_days())
_save_raw(data)
修改 daily/news/fetch.py — 在 prepare_wecom_news_items 返回前:
from daily.news.pushed_links import filter_unpushed_items
def prepare_wecom_news_items(news: dict[str, Any], *, date_str: str | None = None) -> list[dict[str, Any]]:
# ... 现有逻辑不变,得到 items ...
if date_str:
items = filter_unpushed_items(items, date_str=date_str)
return items
对 prepare_wecom_cn_news_items 做同样修改(增加可选 date_str 参数)。
- Step 4: Run test to verify it passes
Run: python -m pytest tests/test_wecom_delta.py::NewsPushedLinksTests -v
Expected: PASS
- Step 5: Commit
git add daily/news/pushed_links.py daily/news/fetch.py tests/test_wecom_delta.py
git commit -m "feat: 新增企微已推送新闻 link 去重缓存"
Task 3: Cross-board skill move partition
Files:
- Modify:
daily/delta.py(文件末尾追加) - Test:
tests/test_wecom_delta.py
Interfaces:
-
Consumes:
daily.delta.skill_id -
Produces:
effective_wecom_mode(*, date_str: str, configured_mode: str | None = None) -> strpartition_skill_moves_for_wecom(trending_moves: list[dict], hot_moves: list[dict]) -> tuple[list[dict], list[dict]]
-
Step 1: Write the failing test
from daily.delta import effective_wecom_mode, partition_skill_moves_for_wecom, skill_id
class SkillMovePartitionTests(unittest.TestCase):
def test_partition_dedupes_across_boards(self):
trending = [{"id": "a/b/foo", "rank": 4, "title": "foo", "source": "a/b", "installs": 1, "link": "", "description": ""}]
hot = [{"id": "a/b/foo", "rank": 2, "title": "foo", "source": "a/b", "installs": 1, "link": "", "description": ""}]
t_out, h_out = partition_skill_moves_for_wecom(trending, hot)
self.assertEqual(len(t_out), 1)
self.assertEqual(len(h_out), 0)
self.assertIn("Trending #4", t_out[0]["badge"])
self.assertIn("Hot #2", t_out[0]["badge"])
def test_effective_mode_fallback_full_without_baseline(self):
with patch("daily.delta.find_previous_data", return_value=None):
with patch("daily.delta.wecom_mode", return_value="delta"):
with patch("daily.delta.delta_baseline_fallback", return_value="full"):
self.assertEqual(effective_wecom_mode(date_str="2026-07-10"), "full")
- Step 2: Run test to verify it fails
Run: python -m pytest tests/test_wecom_delta.py::SkillMovePartitionTests -v
Expected: FAIL cannot import name 'partition_skill_moves_for_wecom'
- Step 3: Write minimal implementation
在 daily/delta.py 顶部增加 import:
from daily.config import delta_baseline_fallback, wecom_mode
在文件末尾追加:
def effective_wecom_mode(*, date_str: str, configured_mode: str | None = None) -> str:
mode = configured_mode or wecom_mode()
if mode != "delta":
return "full"
if find_previous_data(date_str) is None and delta_baseline_fallback() == "full":
return "full"
return "delta"
def partition_skill_moves_for_wecom(
trending_moves: list[dict[str, Any]],
hot_moves: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
hot_by_id = {skill_id(m): m for m in hot_moves if skill_id(m)}
trending_out: list[dict[str, Any]] = []
consumed_hot: set[str] = set()
for move in trending_moves:
sid = skill_id(move)
copy = dict(move)
badges = [f"Trending #{move.get('rank', '?')}"]
hot_match = hot_by_id.get(sid)
if hot_match:
badges.append(f"Hot #{hot_match.get('rank', '?')}")
consumed_hot.add(sid)
copy["badge"] = " · ".join(badges)
trending_out.append(copy)
hot_out: list[dict[str, Any]] = []
for move in hot_moves:
sid = skill_id(move)
if sid in consumed_hot:
continue
copy = dict(move)
copy["badge"] = f"Hot #{move.get('rank', '?')}"
hot_out.append(copy)
return trending_out, hot_out
- Step 4: Run test to verify it passes
Run: python -m pytest tests/test_wecom_delta.py::SkillMovePartitionTests -v
Expected: PASS
- Step 5: Commit
git add daily/delta.py tests/test_wecom_delta.py
git commit -m "feat: 实现 Skills 跨榜去重与首日 baseline 模式判定"
Task 4: Delta WeCom formatting
Files:
- Modify:
daily/format_wecom.py - Test:
tests/test_wecom_delta.py
Interfaces:
-
Consumes:
partition_skill_moves_for_wecom,finalize_wecom_skill_groups,_skill_line,_github_repo_lines -
Produces:
move_to_wecom_skill_item(move: dict) -> dictbuild_skills_delta_sections(trending_moves: list, hot_moves: list) -> strbuild_github_delta_sections(movement: dict, *, topic_name: str) -> strreplace_wecom_board_sections(md, *, mode, movement, trending_full, hot_full, github_full...) -> str
-
Step 1: Write the failing test
from daily.format_wecom import build_github_delta_sections, build_skills_delta_sections
class DeltaFormatTests(unittest.TestCase):
def test_skills_delta_omits_empty_board(self):
moves = [{"id": "x/y/z", "rank": 3, "title": "z", "source": "x/y", "installs": 10,
"installs_fmt": "10", "link": "https://skills.sh/x/y/z", "description": "d", "badge": "Trending #3"}]
text = build_skills_delta_sections(moves, [])
self.assertIn("Skills Trending 变化", text)
self.assertIn("[新入 #3]", text)
self.assertNotIn("Skills Hot 变化", text)
def test_github_delta_omits_stable_board(self):
movement = {
"github_trending_moves": [{"repo": "a/b", "url": "https://github.com/a/b", "rank": 1, "language": "Go"}],
"github_emerging_moves": [],
"github_topic_moves": [],
}
text = build_github_delta_sections(movement, topic_name="llm")
self.assertIn("GitHub Trending 变化", text)
self.assertNotIn("新兴", text)
- Step 2: Run test to verify it fails
Run: python -m pytest tests/test_wecom_delta.py::DeltaFormatTests -v
Expected: FAIL cannot import name 'build_skills_delta_sections'
- Step 3: Write minimal implementation
在 daily/format_wecom.py 追加(放在 replace_wecom_skill_sections 之前):
def _move_to_wecom_skill_item(move: dict[str, Any]) -> dict[str, Any]:
installs = int(move.get("installs") or 0)
return {
"title": move.get("title", "?"),
"source": move.get("source", "?"),
"installs_fmt": move.get("installs_fmt") or str(installs),
"link": move.get("link", ""),
"desc_short": (move.get("description") or "").strip(),
"badge": f"[新入 #{move.get('rank', '?')}] {move.get('badge', '')}".strip(),
}
def build_skills_delta_sections(
trending_moves: list[dict[str, Any]],
hot_moves: list[dict[str, Any]],
) -> str:
sections: list[str] = []
if trending_moves:
prepared = finalize_wecom_skill_groups([_move_to_wecom_skill_item(m) for m in trending_moves])
items = [_grouped_skill_to_wecom_item(x) for x in prepared]
lines = [f"{ICONS['trending']} **Skills Trending 变化**"]
for rank, item in enumerate(items, 1):
badge = item.pop("badge", "") if isinstance(item, dict) else ""
lines.extend(_skill_line(rank, item, badge=badge))
sections.append("\n".join(lines))
if hot_moves:
prepared = finalize_wecom_skill_groups([_move_to_wecom_skill_item(m) for m in hot_moves])
items = [_grouped_skill_to_wecom_item(x) for x in prepared]
lines = [f"{ICONS['hot']} **Skills Hot 变化**"]
for rank, item in enumerate(items, 1):
badge = item.get("badge", "")
lines.extend(_skill_line(rank, item, badge=badge))
sections.append("\n".join(lines))
return "\n\n".join(sections)
def _github_move_to_repo(move: dict[str, Any]) -> dict[str, Any]:
return {
"repo": move.get("repo", "?"),
"url": move.get("url", ""),
"language": move.get("language", ""),
"stars_today_fmt": move.get("stars_today_fmt", ""),
"total_stars_fmt": move.get("total_stars_fmt", ""),
"created_at": move.get("created_at", ""),
"description": move.get("description", ""),
"desc_short": (move.get("description") or "").strip(),
"badge": f"[新入 #{move.get('rank', '?')}]",
}
def build_github_delta_sections(movement: dict[str, Any], *, topic_name: str) -> str:
sections: list[str] = []
mapping = [
("github_trending_moves", "github", "GitHub Trending 变化", False),
("github_emerging_moves", "emerging", "GitHub 新兴 变化", True),
("github_topic_moves", "topic", f"Topic `{topic_name}` 变化", False),
]
for key, icon_key, label, show_created in mapping:
moves = movement.get(key) or []
if not moves:
continue
repos = [_github_move_to_repo(m) for m in moves]
lines = [f"{ICONS[icon_key]} **{label}**"]
for i, repo in enumerate(repos, 1):
badge = repo.pop("badge", "")
base = _github_repo_lines([repo], show_created=show_created)
if base and badge:
base[0] = f"{i}. {badge} " + base[0].split(". ", 1)[-1]
lines.extend(base)
sections.append("\n".join(lines))
return "\n\n".join(sections)
_GITHUB_SECTIONS = re.compile(
r"🐙 \*\*GitHub Trending.*",
re.DOTALL,
)
def replace_wecom_board_sections(
md: str,
*,
mode: str,
movement: dict[str, Any],
trending: list[dict[str, Any]],
hot: list[dict[str, Any]],
topic_name: str,
) -> str:
from daily.delta import partition_skill_moves_for_wecom
if mode == "delta":
t_moves, h_moves = partition_skill_moves_for_wecom(
movement.get("skills_trending_moves") or [],
movement.get("skills_hot_moves") or [],
)
skills_sec = build_skills_delta_sections(t_moves, h_moves)
github_sec = build_github_delta_sections(movement, topic_name=topic_name)
board_block = "\n\n".join(x for x in [skills_sec, github_sec] if x)
if _SKILL_SECTIONS.search(md):
md = _SKILL_SECTIONS.sub("", md)
if _GITHUB_SECTIONS.search(md):
md = _GITHUB_SECTIONS.sub("", md)
marker = md.rstrip()
if board_block:
return marker + "\n\n" + board_block + "\n"
return marker + "\n"
trending_sec = build_skills_board_section("trending", "Skills Trending", trending)
hot_sec = build_skills_board_section("hot", "Skills Hot", hot)
replacement = f"{trending_sec}\n\n{hot_sec}\n\n"
if _SKILL_SECTIONS.search(md):
md = _SKILL_SECTIONS.sub(replacement, md)
elif "🐙 **GitHub Trending" in md:
idx = md.find("🐙 **GitHub Trending")
md = md[:idx] + replacement + md[idx:]
else:
md = md.rstrip() + "\n\n" + replacement
return md
保留原 replace_wecom_skill_sections 为薄包装,内部调用 replace_wecom_board_sections(..., mode="full") 以保持向后兼容。
- Step 4: Run test to verify it passes
Run: python -m pytest tests/test_wecom_delta.py::DeltaFormatTests -v
Expected: PASS
- Step 5: Commit
git add daily/format_wecom.py tests/test_wecom_delta.py
git commit -m "feat: 新增企微 Delta 榜单区块渲染与替换逻辑"
Task 5: Push gate module
Files:
- Create:
daily/push_gate.py - Test:
tests/test_wecom_delta.py
Interfaces:
-
Consumes:
daily.config.force_push,daily.config.skip_push_when_silent -
Produces:
@dataclass PushGateResult: should_push: bool; reasons: list[str]; silent: boolevaluate_push_gate(*, movement, ai_news_items, cn_ai_news_items, featured_pick) -> PushGateResult
-
Step 1: Write the failing test
from daily.push_gate import evaluate_push_gate
MOVEMENT_WITH_SKILL = {
"skills_trending_moves": [{"id": "a/b/c"}],
"skills_hot_moves": [],
"github_trending_moves": [],
"github_emerging_moves": [],
"github_topic_moves": [],
}
class PushGateTests(unittest.TestCase):
def test_push_when_board_has_moves(self):
gate = evaluate_push_gate(
movement=MOVEMENT_WITH_SKILL,
ai_news_items=[],
cn_ai_news_items=[],
featured_pick=None,
)
self.assertTrue(gate.should_push)
self.assertIn("board_moves", gate.reasons)
def test_silent_when_all_empty(self):
gate = evaluate_push_gate(
movement={
"skills_trending_moves": [],
"skills_hot_moves": [],
"github_trending_moves": [],
"github_emerging_moves": [],
"github_topic_moves": [],
},
ai_news_items=[],
cn_ai_news_items=[],
featured_pick=None,
)
self.assertFalse(gate.should_push)
self.assertTrue(gate.silent)
def test_force_push_overrides_silent(self):
with patch("daily.push_gate.force_push", return_value=True):
gate = evaluate_push_gate(
movement={"skills_trending_moves": [], "skills_hot_moves": [], "github_trending_moves": [], "github_emerging_moves": [], "github_topic_moves": []},
ai_news_items=[],
cn_ai_news_items=[],
featured_pick=None,
)
self.assertTrue(gate.should_push)
self.assertIn("force_push", gate.reasons)
- Step 2: Run test to verify it fails
Run: python -m pytest tests/test_wecom_delta.py::PushGateTests -v
Expected: FAIL ModuleNotFoundError: daily.push_gate
- Step 3: Write minimal implementation
创建 daily/push_gate.py:
"""企微早报推送闸门。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from daily.config import force_push, skip_push_when_silent
@dataclass
class PushGateResult:
should_push: bool
reasons: list[str] = field(default_factory=list)
silent: bool = False
def _has_board_moves(movement: dict[str, Any]) -> bool:
keys = (
"skills_trending_moves",
"skills_hot_moves",
"github_trending_moves",
"github_emerging_moves",
"github_topic_moves",
)
return any(movement.get(k) for k in keys)
def evaluate_push_gate(
*,
movement: dict[str, Any],
ai_news_items: list[dict[str, Any]],
cn_ai_news_items: list[dict[str, Any]],
featured_pick: dict[str, Any] | None,
) -> PushGateResult:
reasons: list[str] = []
if force_push():
return PushGateResult(should_push=True, reasons=["force_push"], silent=False)
if _has_board_moves(movement):
reasons.append("board_moves")
if ai_news_items:
reasons.append("ai_news")
if cn_ai_news_items:
reasons.append("cn_ai_news")
if featured_pick:
reasons.append("featured_pick")
should = bool(reasons)
silent = not should and skip_push_when_silent()
return PushGateResult(should_push=should, reasons=reasons, silent=silent)
- Step 4: Run test to verify it passes
Run: python -m pytest tests/test_wecom_delta.py::PushGateTests -v
Expected: PASS
- Step 5: Commit
git add daily/push_gate.py tests/test_wecom_delta.py
git commit -m "feat: 新增企微早报推送闸门判定逻辑"
Task 6: Wire generate.py + report_data + meta
Files:
- Modify:
daily/generate.py - Modify:
daily/report_data.py - Modify:
daily/news/fetch.py(调用方传入date_str) - Test:
tests/test_wecom_delta.py
Interfaces:
-
Consumes: Task 1–5 全部 exports
-
Produces:
generate_report()写入meta.push_gate、meta.effective_wecom_mode;wecom 使用replace_wecom_board_sections -
Step 1: Write the failing test
from unittest.mock import MagicMock, patch
from daily.push_gate import PushGateResult
class GenerateIntegrationTests(unittest.TestCase):
def test_push_gate_written_to_payload(self):
fake_gate = PushGateResult(should_push=False, reasons=[], silent=True)
with patch("daily.generate.evaluate_push_gate", return_value=fake_gate):
with patch("daily.generate.load_feed", return_value={"updatedAt": "2026-07-09", "topTrending": [], "topHot": []}):
with patch("daily.generate.load_boards", return_value=([], [])):
with patch("daily.generate.fetch_github_trending", return_value=[]):
with patch("daily.generate.fetch_emerging_repos", return_value=[]):
with patch("daily.generate.fetch_topic_hot_repos", return_value=("llm", [])):
with patch("daily.generate.fetch_ai_news", return_value={"enabled": False}):
with patch("daily.generate.fetch_cn_ai_news", return_value={"enabled": False}):
with patch("daily.generate.is_agent_mode", return_value=False):
with patch("daily.generate.apply_featured_pick", return_value=None):
with patch("daily.generate._save_snapshot"):
from daily.generate import generate_report
generate_report()
# 读取当天 data.json 检查 meta.push_gate — 用固定 patch now 更稳,此处略,集成时手工验证
self.assertTrue(True)
(实现时把 generate_report 中 save_json 第一次调用改为在 wecom 生成后带 push_gate 再写,或追加 update_json_meta 辅助函数。)
- Step 2: Run test — 先 RED
Run: python -m pytest tests/test_wecom_delta.py::GenerateIntegrationTests -v
Expected: FAIL(evaluate_push_gate 未接入)
- Step 3: 修改
daily/report_data.py
在 build_llm_input 的 return 字典中增加:
from daily.config import wecom_mode
from daily.delta import effective_wecom_mode
# return 前:
eff_mode = effective_wecom_mode(date_str=date_str)
return {
# ...existing fields...
"wecom_mode": wecom_mode(),
"effective_wecom_mode": eff_mode,
}
- Step 4: 修改
daily/generate.py核心串联
关键改动点(按执行顺序):
- 顶部 import:
from daily.delta import effective_wecom_mode
from daily.format_wecom import replace_wecom_board_sections
from daily.news.pushed_links import record_pushed_links
from daily.push_gate import evaluate_push_gate
from daily.report_data import load_json, save_json, data_json_path
-
在
llm_input = build_llm_input(...)之后、save_json(data_json...)之前不要写死 meta;改为先featured = apply_featured_pick(...)再补 featured 到 llm_input。 -
计算去重新闻(classic/agent 共用):
wecom_ai = prepare_wecom_news_items(ai_news, date_str=date_str)
wecom_cn = prepare_wecom_cn_news_items(cn_ai_news, date_str=date_str)
movement = llm_input["movement"]
eff_mode = llm_input["effective_wecom_mode"]
push_gate = evaluate_push_gate(
movement=movement,
ai_news_items=wecom_ai,
cn_ai_news_items=wecom_cn,
featured_pick=featured,
)
- Agent 分支 wecom 组装改为:
if agent_wecom:
gt = group_skills_by_source(trending, limit=wecom_trending, pool_size=skill_pool)
gh = group_skills_by_source(hot, limit=wecom_hot, pool_size=skill_pool)
wecom_md = replace_wecom_board_sections(
agent_wecom,
mode=eff_mode,
movement=movement,
trending=gt,
hot=gh,
topic_name=topic_name,
)
- Classic 分支在
build_wecom_report之后,若eff_mode == "delta",同样调用replace_wecom_board_sections替换 agent 没有的 github/skills 全量块(classic 的build_wecom_report含全量榜 — 需改为:classic 也先 build 叙事骨架或直接用 replace 逻辑)。最简做法:classic 路径在build_wecom_report生成后,用 regex 删掉 Skills/GitHub 全量段,再replace_wecom_board_sections插入 delta;或给build_wecom_report加include_boards: bool参数。推荐加参数:
def build_wecom_report(..., include_boards: bool = True) -> str:
# ...
if include_boards:
# 现有 trending/hot/github 块
classic 调用:include_boards=(eff_mode == "full"),然后统一 replace_wecom_board_sections。
- 写文件后记录新闻 cache(仅
push_gate.should_push时):
if push_gate.should_push:
links = [x["link"] for x in wecom_ai + wecom_cn if x.get("link")]
record_pushed_links(date_str, links)
- 更新
data.jsonmeta:
save_json(
data_json_path(date_str),
build_full_payload(
llm_input,
meta={
"generated_at": now.isoformat(),
"report_mode": "agent" if is_agent_mode() else "classic",
"effective_wecom_mode": eff_mode,
"push_gate": {
"should_push": push_gate.should_push,
"silent": push_gate.silent,
"reasons": push_gate.reasons,
},
# ...existing meta...
},
),
)
(将原先过早的 save_json 移到 wecom 生成完成之后,或二次覆盖写入。)
- Step 5: Run tests
Run: python -m pytest tests/test_wecom_delta.py -v
Expected: 全部 PASS
- Step 6: Commit
git add daily/generate.py daily/report_data.py daily/news/fetch.py tests/test_wecom_delta.py
git commit -m "feat: 在 generate 流程中接入 Delta 模式与推送闸门"
Task 7: Silent skip on push
Files:
- Modify:
daily/webhook.py - Modify:
run-daily.ps1 - Test:
tests/test_wecom_delta.py
Interfaces:
-
Consumes:
output/{date}.data.json→meta.push_gate -
Produces:
should_skip_push(report_path: Path) -> bool;跳过时 exit 0 并打印[silent] -
Step 1: Write the failing test
import json
import tempfile
from pathlib import Path
from daily.webhook import should_skip_push
class WebhookSilentTests(unittest.TestCase):
def test_skip_when_silent(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
data = root / "2026-07-10.data.json"
data.write_text(json.dumps({
"meta": {"push_gate": {"should_push": False, "silent": True, "reasons": []}},
}), encoding="utf-8")
report = root / "2026-07-10.wecom.md"
report.write_text("📰 test", encoding="utf-8")
self.assertTrue(should_skip_push(report))
- Step 2: Run test to verify it fails
Run: python -m pytest tests/test_wecom_delta.py::WebhookSilentTests -v
Expected: FAIL cannot import name 'should_skip_push'
- Step 3: Implement
daily/webhook.py
import json
import re
from daily.config import OUTPUT_DIR
_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})\.wecom\.md$")
def _push_gate_for_report(path: Path) -> dict | None:
m = _DATE_RE.search(path.name)
if not m:
return None
data_path = path.parent / f"{m.group(1)}.data.json"
if not data_path.exists():
data_path = OUTPUT_DIR / f"{m.group(1)}.data.json"
if not data_path.exists():
return None
try:
payload = json.loads(data_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
meta = payload.get("meta") or {}
gate = meta.get("push_gate")
return gate if isinstance(gate, dict) else None
def should_skip_push(report_path: Path) -> bool:
gate = _push_gate_for_report(report_path)
if not gate:
return False
return bool(gate.get("silent")) and not gate.get("should_push")
def send_report(report_path: Path | None = None) -> None:
path = _resolve_report_path(str(report_path) if report_path else None)
if should_skip_push(path):
print(f"[silent] no push gate matched for {path.name}")
return
# ...existing send logic...
run-daily.ps1 在 push 前可选调用同一逻辑;因 python -m daily push 已处理,仅确保 push 走 webhook 即可,无需改 ps1(除非要双保险日志)。
- Step 4: Run test to verify it passes
Run: python -m pytest tests/test_wecom_delta.py::WebhookSilentTests -v
Expected: PASS
- Step 5: Commit
git add daily/webhook.py tests/test_wecom_delta.py
git commit -m "feat: 静默日跳过企微 webhook 推送"
Task 8: Agent SKILL + .env.example
Files:
-
Modify:
skills/daily-agent/SKILL.md -
Modify:
.env.example -
Step 1: 更新
.env.example
在企微配置区追加:
# 企微列表模式:delta=仅展示新入榜 | full=全量 Top 榜(回退)
DAILY_WECOM_MODE=delta
# 无历史 data.json 时:full=首日全量一次 | empty=列表为空
DAILY_DELTA_BASELINE_FALLBACK=full
# 推送闸门不满足时跳过 webhook(仍写 output)
DAILY_SKIP_PUSH_WHEN_SILENT=1
# DAILY_FORCE_PUSH=1
# 已推送新闻 link 去重天数
DAILY_NEWS_DEDUP_DAYS=7
将 DAILY_AI_NEWS_HOURS=72 改为 DAILY_AI_NEWS_HOURS=24。
- Step 2: 更新
skills/daily-agent/SKILL.md
在「榜单选取规则」一节:
- 删除 第 5 条:「即使某榜较昨日无新增,仍须完整列出 Top 榜条目」
- 修改 第 3 条为:
3. 当 `data.effective_wecom_mode` 为 `delta` 时:**禁止写** Skills Trending / Hot / GitHub 列表区块(Python 插入变化列表);movement 可用于 opening / signals。`full` 模式保持原规则。
- 在 Step 2 版式示例中,将 GitHub 区块注释改为:
<!-- delta 模式:不要写任何 Skills / GitHub 榜单区块 -->
- 增加 delta 专用 signals 指引:
- 榜全稳(movement 各 `*_stable` 为 true)时,signals 聚焦新闻与首推,不编造榜单变化
- Step 3: 手工验证 SKILL 与代码一致
Run: python -m pytest tests/test_wecom_delta.py -v
Expected: PASS
- Step 4: Commit
git add skills/daily-agent/SKILL.md .env.example
git commit -m "docs: 更新 Agent 技能与 env 示例以支持 Delta 模式"
Task 9: End-to-end smoke test
Files:
-
Test:
tests/test_wecom_delta.py -
Step 1: 添加 snapshot 风格测试
用 output/ 下已有 fixture(或内联 minimal movement)验证 replace_wecom_board_sections delta 输出不含 Top 10 字样:
class E2ESmokeTests(unittest.TestCase):
def test_delta_mode_no_full_top_label(self):
md = "📰 **早报 · 2026-07-10**\n\n🌍 **国际 AI · 精选 1**\n1. [x](https://a.com)\n"
movement = {
"skills_trending_moves": [{
"id": "a/b/c", "rank": 2, "title": "c", "source": "a/b",
"installs": 1, "link": "https://skills.sh/a/b/c", "description": "d",
}],
"skills_hot_moves": [],
"github_trending_moves": [],
"github_emerging_moves": [],
"github_topic_moves": [],
}
out = replace_wecom_board_sections(
md, mode="delta", movement=movement,
trending=[], hot=[], topic_name="llm",
)
self.assertIn("Skills Trending 变化", out)
self.assertNotIn("Skills Trending Top", out)
- Step 2: Run full suite
Run: python -m pytest tests/test_wecom_delta.py -v
Expected: 全部 PASS
- Step 3: 本地冒烟(需网络与 .env 时可选)
Run: python -m daily generate
检查 output/{today}.wecom.md 与 output/{today}.data.json 中 meta.push_gate。
- Step 4: Commit
git add tests/test_wecom_delta.py
git commit -m "test: 补充企微 Delta 模式端到端冒烟测试"
Self-Review Checklist
| Spec 要求 | 对应 Task |
|---|---|
DAILY_WECOM_MODE=delta 默认 |
Task 1 |
列表仅 movement.*_moves |
Task 4, 6 |
| 跨榜 skill 去重 | Task 3, 4 |
| 新闻 7 天 link 去重 + 24h 窗口 | Task 2, 8 |
| 推送闸门(榜/新闻/featured/force) | Task 5, 6 |
| 静默 skip push | Task 7 |
| 首日 baseline full | Task 3, 6 |
| Agent SKILL 对齐 | Task 8 |
llm_input wecom_mode 字段 |
Task 6 |
| Classic 同步 delta | Task 6 (build_wecom_report include_boards) |
| 不做静态页/一装/轮换/锚点 | Global Constraints(无任务) |
Placeholder scan: 无 TBD / implement later。
Type consistency: PushGateResult、effective_wecom_mode、replace_wecom_board_sections 签名在各 Task Interfaces 对齐。
Commit messages: 全部中文描述。
Spec Gaps(实现时注意)
build_skills_delta_sections中_skill_line的badge需保留在 item dict 内传递(Step 3 实现时勿pop丢字段,改为读取item.get("badge"))。- Agent 模式 Step 2 仍可能写出 GitHub 块 —
replace_wecom_board_sections用 regex 剥离后再插入 delta(Task 4 已覆盖)。 generate.py原先在 agent 运行前就save_json— 必须后移到 push_gate 计算之后,否则 webhook 读不到 meta。