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

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