Compare commits

...

8 Commits

Author SHA1 Message Date
d66f2c716c docs: env 示例补充 DAILY_WECOM_TOP_LINE / DAILY_FEATURED_REASON
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:08:35 +08:00
d9aef4b340 chore: 删 tmp 调试文件,裸 except 改命名异常+日志
- 删除被跟踪的 tmp_check_desc.py / tmp_desc_snip.txt
- bridge_manager discovery、generate release 抓取的裸
  except Exception 改命名异常 + logger.warning(T8)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:01:20 +08:00
d7992e7a7a feat: push_gate 判定分支加结构化日志
force/推送/静默日/无更新仍推 四分支各记一条 logger.info,
无人值守时可定位「早报没推是静默日还是别的原因」(T7)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:55:47 +08:00
3321a307a0 feat: 首推理由行加 DAILY_FEATURED_REASON 开关
pick_why 本已渲染,现加开关默认开,空则省略(T6)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:53:19 +08:00
33bfed0e79 feat: theme_line 取数升级,无评分命中时回退 theme_names
新增 _top_line: 优先评分最高主题,无命中时回退 _theme_clusters 的
主题名(非 markdown 示例),DAILY_WECOM_TOP_LINE=0 退回原逻辑。
复用现有 theme_line 行,不新增头部行(T5)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:44:15 +08:00
ea8de9fe61 test: 新增 generate_report 黄金/确定性回归
冻结 _now_cst、mock 网络/文件/LLM(featured_pick 与 push_gate
有独立测试,此处冻结),验证同一输入下完整版与企微版产物逐字节
一致——拆分 _collect/_select/_render 不得改变行为(T1+T4)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:35:42 +08:00
cd92644be4 refactor: 拆 generate_report 为 _collect/_select/_render 三段
所有选择逻辑(9 处 board_select + featured_pick + _localize + pad)
全归 _select,_render 纯拼装写盘,薄壳 generate_report 仅编排。
结构化 bundle 传参;副作用(record_pushed_links/_save_snapshot/
save_json)保持原时序在 _render 末尾。行为不变,89 测试绿(T2)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:02:32 +08:00
92e0ed9a27 refactor: _theme_clusters 迁入 narrative_axis 并以注入消除反向依赖
theme_clusters/theme_names 经参数注入 THEME_RULES 与 skill_id_fn,
避免 generate.py 的循环 import(T3,为拆分 generate_report 铺路)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:49:37 +08:00
11 changed files with 574 additions and 140 deletions

View File

@@ -8,6 +8,10 @@ DAILY_GITHUB_TRENDING_LIMIT=10
DAILY_GITHUB_EMERGING_LIMIT=10 DAILY_GITHUB_EMERGING_LIMIT=10
DAILY_GITHUB_TOPIC_LIMIT=10 DAILY_GITHUB_TOPIC_LIMIT=10
# 企微版可选行(默认均开)
# DAILY_WECOM_TOP_LINE=1 # 顶部「今日主题」行,无评分命中时回退主题名
# DAILY_FEATURED_REASON=1 # 首推下「为什么值得点开」理由行
# GitHub Trending # GitHub Trending
GITHUB_TRENDING_SINCE=daily GITHUB_TRENDING_SINCE=daily
# GITHUB_TRENDING_LANGUAGE=python # GITHUB_TRENDING_LANGUAGE=python

View File

@@ -124,7 +124,8 @@ def warm_cursor_bridge(force: bool = False) -> None:
) )
try: try:
discovery = _read_discovery_polling(process) discovery = _read_discovery_polling(process)
except Exception: except (RuntimeError, OSError, ValueError) as exc:
logger.warning("Cursor bridge discovery 失败,终止子进程:%s", exc)
process.kill() process.kill()
process.wait(timeout=5) process.wait(timeout=5)
raise raise

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import re import re
from typing import Any from typing import Any
from daily.config import wecom_skill_desc_limit from daily.config import env_bool, wecom_skill_desc_limit
from daily.localize import LocalizeJob, localize_brief_descriptions, needs_chinese from daily.localize import LocalizeJob, localize_brief_descriptions, needs_chinese
from daily.skills_group import group_skills_by_source from daily.skills_group import group_skills_by_source
from daily.text_utils import trim_brief from daily.text_utils import trim_brief
@@ -1096,7 +1096,8 @@ def build_wecom_report(
lines.append(f"{ICONS['pick']} **今日首推**") lines.append(f"{ICONS['pick']} **今日首推**")
lines.append(_format_pick_link(pick_command, title=pick_title, url=pick_url)) lines.append(_format_pick_link(pick_command, title=pick_title, url=pick_url))
if pick_why: # 首推「为什么值得点开」一句理由; DAILY_FEATURED_REASON=0 关闭, 空则省略
if pick_why and env_bool("DAILY_FEATURED_REASON", True):
lines.append(f"> {pick_why}") lines.append(f"> {pick_why}")
return "\n".join(lines) return "\n".join(lines)

View File

@@ -24,6 +24,7 @@ from daily.config import (
SNAPSHOT_FILE, SNAPSHOT_FILE,
board_pool_size, board_pool_size,
env, env,
env_bool,
env_int, env_int,
full_desc_limit, full_desc_limit,
news_summary_limit, news_summary_limit,
@@ -65,6 +66,7 @@ from daily.github.auth import github_html_headers
from daily.github.search import fetch_emerging_repos, fetch_topic_hot_repos from daily.github.search import fetch_emerging_repos, fetch_topic_hot_repos
from daily.github.trending import fetch_github_trending, trending_data_source_note from daily.github.trending import fetch_github_trending, trending_data_source_note
from daily.localize import LocalizeJob, localize_descriptions, needs_chinese from daily.localize import LocalizeJob, localize_descriptions, needs_chinese
from daily.narrative_axis import theme_clusters, theme_names
from daily.news.fetch import ( from daily.news.fetch import (
fetch_ai_news, fetch_ai_news,
fetch_cn_ai_news, fetch_cn_ai_news,
@@ -344,6 +346,33 @@ def _detect_theme_line(feed: dict[str, Any]) -> str:
return f"**今日主题**{max(scores.items(), key=lambda x: x[1])[0]}" return f"**今日主题**{max(scores.items(), key=lambda x: x[1])[0]}"
def _top_line(feed: dict[str, Any]) -> str:
"""今日看点行: 优先评分最高的主题, 回退 _theme_clusters 的主题名(非 markdown 示例)。
DAILY_WECOM_TOP_LINE=0 关闭时退回 _detect_theme_line 原逻辑。
与原 _detect_theme_line 的差别仅在「无评分命中」时的兜底文案:
用 theme_names 的真实主题取代硬编码「Agent Skills 生态持续活跃」。
"""
if not env_bool("DAILY_WECOM_TOP_LINE", True):
return _detect_theme_line(feed)
scores: dict[str, int] = defaultdict(int)
for board in ("topTrending", "topHot"):
for rank, item in enumerate(feed.get(board, [])[:10], 1):
haystack = " ".join(
[item.get("title", ""), item.get("source", ""), item.get("description", "")]
).lower()
for _icon, label, keywords in THEME_RULES:
if any(k in haystack for k in keywords):
scores[label] += max(1, 11 - rank)
break
if scores:
return f"**今日主题**{max(scores.items(), key=lambda x: x[1])[0]}"
names = theme_names(feed, theme_rules=THEME_RULES, skill_id_fn=_skill_id)
if names:
return f"**今日主题**{' · '.join(names)}"
return "**今日主题**Agent Skills 生态持续活跃"
def _build_highlights( def _build_highlights(
trending: list[dict[str, Any]], trending: list[dict[str, Any]],
hot: list[dict[str, Any]], hot: list[dict[str, Any]],
@@ -444,31 +473,11 @@ def _fetch_latest_release_title(repo: str) -> str | None:
return None return None
title = entry.find("a:title", ns) title = entry.find("a:title", ns)
return title.text.strip() if title is not None and title.text else None return title.text.strip() if title is not None and title.text else None
except Exception: except (httpx.HTTPError, ET.ParseError, OSError) as exc:
logger.warning("读取 %s release 失败:%s", atom_url, exc)
return None return None
def _theme_clusters(feed: dict[str, Any], limit: int = 5) -> list[tuple[str, list[str]]]:
buckets: dict[str, list[str]] = defaultdict(list)
seen: set[str] = set()
for board in ("topTrending", "topHot"):
for item in feed.get(board, [])[:20]:
item_id = _skill_id(item)
if item_id in seen:
continue
seen.add(item_id)
haystack = " ".join(
[item.get("title", ""), item.get("source", ""), item.get("description", "")]
).lower()
for _icon, theme, keywords in THEME_RULES:
if any(k in haystack for k in keywords):
label = f"**{item.get('title')}** (`{item.get('source')}`)"
if label not in buckets[theme]:
buckets[theme].append(label)
break
return [(theme, examples[:limit]) for theme, examples in buckets.items() if examples]
def _format_github_repo_section(repos: list[dict[str, Any]], *, show_created: bool = False) -> list[str]: def _format_github_repo_section(repos: list[dict[str, Any]], *, show_created: bool = False) -> list[str]:
lines: list[str] = [] lines: list[str] = []
for i, repo in enumerate(repos, 1): for i, repo in enumerate(repos, 1):
@@ -509,7 +518,11 @@ def _format_skill_section(items: list[dict[str, Any]], *, hot: bool = False) ->
return lines return lines
def generate_report() -> tuple[str, str, Path, Path]: def _collect(date_str: str) -> dict[str, Any]:
"""抓取 skills/github/news 数据并归一化,计算 wecom 限额与周去重 recent keys。
无副作用(不写快照/不记已推)。返回供 _select/_render 消费的 bundle。
"""
# Hot/Trending 前排同 source 极密,需更深抓取才能凑够展示用的唯一 source # Hot/Trending 前排同 source 极密,需更深抓取才能凑够展示用的唯一 source
trending_n = env_int("DAILY_TRENDING_LIMIT", 400) trending_n = env_int("DAILY_TRENDING_LIMIT", 400)
hot_n = max(env_int("DAILY_HOT_LIMIT", 400), compare_depth()) hot_n = max(env_int("DAILY_HOT_LIMIT", 400), compare_depth())
@@ -590,11 +603,79 @@ def generate_report() -> tuple[str, str, Path, Path]:
| recent_shown["github_emerging"] | recent_shown["github_emerging"]
| recent_shown["github_topic"] | recent_shown["github_topic"]
) )
return {
"trending_n": trending_n,
"hot_n": hot_n,
"github_limit": github_limit,
"emerging_limit": emerging_limit,
"topic_limit": topic_limit,
"wecom_trending": wecom_trending,
"wecom_hot": wecom_hot,
"wecom_github": wecom_github,
"wecom_emerging": wecom_emerging,
"wecom_topic": wecom_topic,
"wecom_limits": wecom_limits,
"pool": pool,
"pad_pool": pad_pool,
"feed": feed,
"prev_ids": prev_ids,
"now": now,
"date_str": date_str,
"time_str": time_str,
"updated": updated,
"trending": trending,
"hot": hot,
"github_trending": github_trending,
"github_emerging": github_emerging,
"github_topic": github_topic,
"topic_name": topic_name,
"news_merged": news_merged,
"ai_news": ai_news,
"cn_ai_news": cn_ai_news,
"ai_news_research": ai_news_research,
"wecom_news": wecom_news,
"wecom_tech_news": wecom_tech_news,
"recent_shown": recent_shown,
"skill_recent": skill_recent,
"github_recent": github_recent,
}
def _select(c: dict[str, Any]) -> dict[str, Any]:
"""全部「选择 + 数据突变」:9 处 board_select + featured_pick + LLM 步骤 + pad。
时序约束内聚本段:featured pick 依赖 deep-pool;_localize 在 featured 后、
pad 前执行;写回 shown keys/llm_input 也在此。_render 不再做任何选择。
副作用: apply_featured_pick 写回 shown keys(保持原时序)。
"""
date_str = c["date_str"]
feed = c["feed"]
prev_ids = c["prev_ids"]
updated = c["updated"]
time_str = c["time_str"]
trending = c["trending"]
hot = c["hot"]
github_trending = c["github_trending"]
github_emerging = c["github_emerging"]
github_topic = c["github_topic"]
topic_name = c["topic_name"]
news_merged = c["news_merged"]
ai_news = c["ai_news"]
cn_ai_news = c["cn_ai_news"]
wecom_news = c["wecom_news"]
wecom_tech_news = c["wecom_tech_news"]
wecom_limits = c["wecom_limits"]
pool = c["pool"]
pad_pool = c["pad_pool"]
recent_shown = c["recent_shown"]
skill_recent = c["skill_recent"]
github_recent = c["github_recent"]
selected_trending = board_select( selected_trending = board_select(
board="skills_trending", board="skills_trending",
items=trending, items=trending,
recent_keys=skill_recent, recent_keys=skill_recent,
limit=wecom_trending, limit=c["wecom_trending"],
pool_size=pool, pool_size=pool,
kind="skill", kind="skill",
) )
@@ -602,7 +683,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
board="skills_hot", board="skills_hot",
items=hot, items=hot,
recent_keys=skill_recent, recent_keys=skill_recent,
limit=wecom_hot, limit=c["wecom_hot"],
pool_size=pool, pool_size=pool,
kind="skill", kind="skill",
) )
@@ -610,7 +691,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
board="github_trending", board="github_trending",
items=github_trending, items=github_trending,
recent_keys=github_recent, recent_keys=github_recent,
limit=wecom_github, limit=c["wecom_github"],
pool_size=pool, pool_size=pool,
kind="github", kind="github",
) )
@@ -619,7 +700,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
board="github_emerging", board="github_emerging",
items=github_emerging, items=github_emerging,
recent_keys=github_recent, recent_keys=github_recent,
limit=wecom_emerging, limit=c["wecom_emerging"],
pool_size=pool, pool_size=pool,
kind="github", kind="github",
) )
@@ -628,7 +709,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
board="github_topic", board="github_topic",
items=github_topic, items=github_topic,
recent_keys=github_recent, recent_keys=github_recent,
limit=wecom_topic, limit=c["wecom_topic"],
pool_size=pool, pool_size=pool,
kind="github", kind="github",
) )
@@ -759,7 +840,179 @@ def generate_report() -> tuple[str, str, Path, Path]:
github_topic=github_topic, github_topic=github_topic,
) )
themes = _theme_clusters(feed) themes = theme_clusters(feed, theme_rules=THEME_RULES, skill_id_fn=_skill_id)
pick_src = trending[0].get("source", "") if trending else ""
pick_name = trending[0].get("title", "") if trending else ""
pick_command = pick_command_from_featured(featured) or (
f"npx skills add {pick_src}/{pick_name}"
if pick_src and pick_name
else "npx skills add vercel-labs/skills/find-skills"
)
pick_why = pick_why_from_featured(featured) or ""
pick_title = str((featured or {}).get("title") or pick_name or "").strip()
pick_url = str((featured or {}).get("url") or "").strip()
gt = selected_trending
gh = selected_hot
gt_pad = board_select(
board="skills_trending",
items=trending,
recent_keys=skill_recent,
limit=pad_pool,
pool_size=pool,
kind="skill",
)
gh_pad = board_select(
board="skills_hot",
items=hot,
recent_keys=skill_recent,
limit=pad_pool,
pool_size=pool,
kind="skill",
)
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=github_pad_recent,
limit=pad_pool,
pool_size=pool,
kind="github",
)
]
wecom_emerging_pad = [
_prepare_github_item(item)
for item in board_select(
board="github_emerging",
items=github_emerging,
recent_keys=github_pad_recent,
limit=pad_pool,
pool_size=pool,
kind="github",
)
]
wecom_topic_pad = [
_prepare_github_item(item)
for item in board_select(
board="github_topic",
items=github_topic,
recent_keys=github_pad_recent,
limit=pad_pool,
pool_size=pool,
kind="github",
)
]
delta_pad = eff_mode == "delta" and wecom_delta_pad()
board_kwargs = {
"mode": eff_mode,
"movement": movement,
"trending": gt,
"hot": gh,
"topic_name": topic_name,
"github_trending": wecom_github_items,
"github_emerging": wecom_emerging_items,
"github_topic": wecom_topic_items,
"wecom_trending": c["wecom_trending"],
"wecom_hot": c["wecom_hot"],
"wecom_github": c["wecom_github"],
"wecom_emerging": c["wecom_emerging"],
"wecom_topic": c["wecom_topic"],
"pad": delta_pad,
"date_str": date_str,
"trending_pad": gt_pad,
"hot_pad": gh_pad,
"github_trending_pad": wecom_github_pad,
"github_emerging_pad": wecom_emerging_pad,
"github_topic_pad": wecom_topic_pad,
}
return {
"selected_trending": selected_trending,
"selected_hot": selected_hot,
"boards_for_wecom": boards_for_wecom,
"llm_input": llm_input,
"featured": featured,
"movement": movement,
"eff_mode": eff_mode,
"push_gate": push_gate,
"wecom_ai": wecom_ai,
"wecom_cn": wecom_cn,
"agent_wecom": agent_wecom,
"editorial_theme": editorial_theme,
"editorial_highlights": editorial_highlights,
"themes": themes,
"pick_command": pick_command,
"pick_why": pick_why,
"pick_title": pick_title,
"pick_url": pick_url,
"board_kwargs": board_kwargs,
"prev_ids": prev_ids,
"gt": gt,
"gh": gh,
"wecom_github_items": wecom_github_items,
"wecom_emerging_items": wecom_emerging_items,
"wecom_topic_items": wecom_topic_items,
}
def _render(c: dict[str, Any], s: dict[str, Any]) -> tuple[str, str, Path, Path]:
"""纯拼装: 拼完整版 markdown + 企微 wecom_md + 写盘。
零选择逻辑。副作用: record_pushed_links + _save_snapshot + save_json + 写文件,
全部保持原时序(在拼装完成后执行)。
"""
feed = c["feed"]
now = c["now"]
date_str = c["date_str"]
time_str = c["time_str"]
updated = c["updated"]
trending = c["trending"]
hot = c["hot"]
github_trending = c["github_trending"]
github_emerging = c["github_emerging"]
github_topic = c["github_topic"]
topic_name = c["topic_name"]
news_merged = c["news_merged"]
ai_news = c["ai_news"]
cn_ai_news = c["cn_ai_news"]
ai_news_research = c["ai_news_research"]
wecom_news = c["wecom_news"]
wecom_tech_news = c["wecom_tech_news"]
trending_n = c["trending_n"]
hot_n = c["hot_n"]
github_limit = c["github_limit"]
emerging_limit = c["emerging_limit"]
topic_limit = c["topic_limit"]
prev_ids = s["prev_ids"]
llm_input = s["llm_input"]
featured = s["featured"]
eff_mode = s["eff_mode"]
push_gate = s["push_gate"]
wecom_ai = s["wecom_ai"]
wecom_cn = s["wecom_cn"]
agent_wecom = s["agent_wecom"]
editorial_theme = s["editorial_theme"]
editorial_highlights = s["editorial_highlights"]
themes = s["themes"]
pick_command = s["pick_command"]
pick_why = s["pick_why"]
pick_title = s["pick_title"]
pick_url = s["pick_url"]
board_kwargs = s["board_kwargs"]
gt = s["gt"]
gh = s["gh"]
wecom_github_items = s["wecom_github_items"]
wecom_emerging_items = s["wecom_emerging_items"]
wecom_topic_items = s["wecom_topic_items"]
lines = [ lines = [
f"# 早报 · {date_str}", f"# 早报 · {date_str}",
@@ -834,17 +1087,6 @@ def generate_report() -> tuple[str, str, Path, Path]:
lines.append(f"- {ex}") lines.append(f"- {ex}")
lines.append("") lines.append("")
pick_src = trending[0].get("source", "") if trending else ""
pick_name = trending[0].get("title", "") if trending else ""
pick_command = pick_command_from_featured(featured) or (
f"npx skills add {pick_src}/{pick_name}"
if pick_src and pick_name
else "npx skills add vercel-labs/skills/find-skills"
)
pick_why = pick_why_from_featured(featured) or ""
pick_title = str((featured or {}).get("title") or pick_name or "").strip()
pick_url = str((featured or {}).get("url") or "").strip()
lines.extend(["---", "", "## 安装示例", "", "```bash"]) lines.extend(["---", "", "## 安装示例", "", "```bash"])
for item in trending[:4]: for item in trending[:4]:
src, name = item.get("source", ""), item.get("title", "") src, name = item.get("source", ""), item.get("title", "")
@@ -853,88 +1095,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
lines.extend(["```", "", f"*企微短版见 `output/{date_str}.wecom.md`*"]) lines.extend(["```", "", f"*企微短版见 `output/{date_str}.wecom.md`*"])
markdown = "\n".join(lines) markdown = "\n".join(lines)
gt = selected_trending
gh = selected_hot
gt_pad = board_select(
board="skills_trending",
items=trending,
recent_keys=skill_recent,
limit=pad_pool,
pool_size=pool,
kind="skill",
)
gh_pad = board_select(
board="skills_hot",
items=hot,
recent_keys=skill_recent,
limit=pad_pool,
pool_size=pool,
kind="skill",
)
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=github_pad_recent,
limit=pad_pool,
pool_size=pool,
kind="github",
)
]
wecom_emerging_pad = [
_prepare_github_item(item)
for item in board_select(
board="github_emerging",
items=github_emerging,
recent_keys=github_pad_recent,
limit=pad_pool,
pool_size=pool,
kind="github",
)
]
wecom_topic_pad = [
_prepare_github_item(item)
for item in board_select(
board="github_topic",
items=github_topic,
recent_keys=github_pad_recent,
limit=pad_pool,
pool_size=pool,
kind="github",
)
]
delta_pad = eff_mode == "delta" and wecom_delta_pad()
board_kwargs = {
"mode": eff_mode,
"movement": movement,
"trending": gt,
"hot": gh,
"topic_name": topic_name,
"github_trending": wecom_github_items,
"github_emerging": wecom_emerging_items,
"github_topic": wecom_topic_items,
"wecom_trending": wecom_trending,
"wecom_hot": wecom_hot,
"wecom_github": wecom_github,
"wecom_emerging": wecom_emerging,
"wecom_topic": wecom_topic,
"pad": delta_pad,
"date_str": date_str,
"trending_pad": gt_pad,
"hot_pad": gh_pad,
"github_trending_pad": wecom_github_pad,
"github_emerging_pad": wecom_emerging_pad,
"github_topic_pad": wecom_topic_pad,
}
if agent_wecom: if agent_wecom:
wecom_md = replace_wecom_skill_sections(agent_wecom, **board_kwargs) wecom_md = replace_wecom_skill_sections(agent_wecom, **board_kwargs)
else: else:
@@ -944,7 +1105,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
updated=updated, updated=updated,
highlights=editorial_highlights highlights=editorial_highlights
or _build_highlights(trending, hot, github_trending, github_emerging, ai_news, cn_ai_news), or _build_highlights(trending, hot, github_trending, github_emerging, ai_news, cn_ai_news),
theme_line=editorial_theme or _detect_theme_line(feed), theme_line=editorial_theme or _top_line(feed),
ai_news=wecom_ai if not news_merged else None, ai_news=wecom_ai if not news_merged else None,
cn_ai_news=wecom_cn if not news_merged else None, cn_ai_news=wecom_cn if not news_merged else None,
merged_ai_news=wecom_news if news_merged else None, merged_ai_news=wecom_news if news_merged else None,
@@ -1020,6 +1181,13 @@ def generate_report() -> tuple[str, str, Path, Path]:
return markdown, wecom_md, out_md, out_wecom return markdown, wecom_md, out_md, out_wecom
def generate_report() -> tuple[str, str, Path, Path]:
"""编排三段: 抓取(_collect) → 选择(_select) → 拼装(_render)。"""
collected = _collect(_now_cst().strftime("%Y-%m-%d"))
selected = _select(collected)
return _render(collected, selected)
def main() -> int: def main() -> int:
LOG_DIR.mkdir(parents=True, exist_ok=True) LOG_DIR.mkdir(parents=True, exist_ok=True)
log_file = LOG_DIR / f"{_now_cst():%Y-%m-%d}.log" log_file = LOG_DIR / f"{_now_cst():%Y-%m-%d}.log"

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import json import json
import logging import logging
import random import random
from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
@@ -101,3 +102,48 @@ def enforce_narrative_axis(trends: dict[str, Any], axis: str) -> dict[str, Any]:
out = dict(trends) out = dict(trends)
out["narrative_axis"] = axis out["narrative_axis"] = axis
return out return out
def theme_clusters(
feed: dict[str, Any],
*,
limit: int = 5,
theme_rules: list[tuple[str, str, list[str]]],
skill_id_fn: Any,
) -> list[tuple[str, list[str]]]:
"""按 THEME_RULES 把 feed 的 topTrending/topHot 聚成 (主题, 示例列表)。
从 generate.py 迁入(原私有 _theme_clusters。theme_rules 与 skill_id_fn
由调用方注入,避免对 generate.py 的反向依赖(防循环 import
"""
buckets: dict[str, list[str]] = defaultdict(list)
seen: set[str] = set()
for board in ("topTrending", "topHot"):
for item in feed.get(board, [])[:20]:
item_id = skill_id_fn(item)
if item_id in seen:
continue
seen.add(item_id)
haystack = " ".join(
[item.get("title", ""), item.get("source", ""), item.get("description", "")]
).lower()
for _icon, theme, keywords in theme_rules:
if any(k in haystack for k in keywords):
label = f"**{item.get('title')}** (`{item.get('source')}`)"
if label not in buckets[theme]:
buckets[theme].append(label)
break
return [(theme, examples[:limit]) for theme, examples in buckets.items() if examples]
def theme_names(
feed: dict[str, Any],
*,
theme_rules: list[tuple[str, str, list[str]]],
skill_id_fn: Any,
limit: int = 3,
) -> list[str]:
"""仅取主题名(不含 markdown 示例),供「今日看点/theme_line」回退文案。"""
return [theme for theme, _ in theme_clusters(
feed, theme_rules=theme_rules, skill_id_fn=skill_id_fn
)][:limit]

View File

@@ -2,11 +2,14 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from daily.config import force_push, skip_push_when_silent from daily.config import force_push, skip_push_when_silent
logger = logging.getLogger(__name__)
@dataclass @dataclass
class PushGateResult: class PushGateResult:
@@ -34,6 +37,7 @@ def evaluate_push_gate(
featured_pick: dict[str, Any] | None, featured_pick: dict[str, Any] | None,
) -> PushGateResult: ) -> PushGateResult:
if force_push(): if force_push():
logger.info("push_gate: force_push=on, 强制推送")
return PushGateResult(should_push=True, reasons=["force_push"], silent=False) return PushGateResult(should_push=True, reasons=["force_push"], silent=False)
reasons: list[str] = [] reasons: list[str] = []
@@ -48,4 +52,10 @@ def evaluate_push_gate(
should = bool(reasons) should = bool(reasons)
silent = not should and skip_push_when_silent() silent = not should and skip_push_when_silent()
if should:
logger.info("push_gate: 推送 (原因: %s)", ",".join(reasons))
elif silent:
logger.info("push_gate: 静默日, 跳过推送 (无任何更新信号)")
else:
logger.info("push_gate: 无更新但 skip_push_when_silent=off, 仍推送")
return PushGateResult(should_push=should, reasons=reasons, silent=silent) return PushGateResult(should_push=should, reasons=reasons, silent=silent)

View File

@@ -0,0 +1,55 @@
# tests/test_featured_reason.py
"""T6: 首推理由行(pick_why + DAILY_FEATURED_REASON 开关)测试。"""
from __future__ import annotations
import unittest
from unittest import mock
import daily.format_wecom as fw
def _build(pick_why: str) -> str:
return fw.build_wecom_report(
date_str="2026-07-18",
time_str="08:50 (UTC+8)",
updated="2026-07-18",
highlights=[],
theme_line="**今日主题**:测试",
ai_news=None,
cn_ai_news=None,
merged_ai_news=None,
merged_tech_ai_news=None,
trending=[],
hot=[],
repos=[],
emerging=[],
topic_name="t",
topic_repos=[],
pick_command="npx skills add src/alpha",
pick_why=pick_why,
pick_title="alpha",
pick_url="https://x/a",
include_boards=False,
)
class FeaturedReasonTests(unittest.TestCase):
def test_reason_shown_when_present_and_enabled(self):
with mock.patch.object(fw, "env_bool", return_value=True):
md = _build("昨日 star 增速第一")
self.assertIn("> 昨日 star 增速第一", md)
def test_reason_hidden_when_switch_off(self):
with mock.patch.object(fw, "env_bool", return_value=False):
md = _build("昨日 star 增速第一")
self.assertNotIn("> 昨日 star 增速第一", md)
def test_reason_omitted_when_empty(self):
with mock.patch.object(fw, "env_bool", return_value=True):
md = _build("")
self.assertIn("今日首推", md)
self.assertNotIn("> \n", md)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,103 @@
# tests/test_generate_golden.py
"""黄金文件/确定性回归: 冻结时间、mock 网络与 LLM, 验证 generate_report
在同一输入下产出逐字节一致(拆分 _collect/_select/_render 不得改变行为)。"""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest import mock
import daily.generate as g
FIXED_NOW = datetime(2026, 7, 18, 8, 50, tzinfo=timezone(timedelta(hours=8)))
def _feed() -> dict:
return {
"updatedAt": "2026-07-18T08:00:00",
"topTrending": [
{"id": "a", "source": "src", "title": "alpha", "description": "desc a",
"installs": 100, "link": "https://x/a"},
{"id": "b", "source": "src", "title": "beta", "description": "desc b",
"installs": 90, "link": "https://x/b"},
],
"topHot": [
{"id": "c", "source": "src", "title": "gamma", "description": "desc c",
"installs": 80, "link": "https://x/c"},
],
}
def _patches(tmp: Path):
"""集中 mock 所有外部边界: 网络/文件/时间/LLM。返回 patcher 列表。"""
return [
mock.patch.object(g, "_now_cst", return_value=FIXED_NOW),
mock.patch.object(g, "load_feed", return_value=_feed()),
mock.patch.object(g, "_load_snapshot", return_value=set()),
mock.patch.object(g, "_save_snapshot", lambda *a, **k: None),
mock.patch.object(g, "load_boards", return_value=(
_feed()["topTrending"], _feed()["topHot"])),
mock.patch.object(g, "fetch_github_trending", return_value=[]),
mock.patch.object(g, "fetch_emerging_repos", return_value=[]),
mock.patch.object(g, "fetch_topic_hot_repos", return_value=("topic", [])),
mock.patch.object(g, "fetch_ai_news", return_value={
"enabled": False, "categories": [], "flat": [], "stats": {}}),
mock.patch.object(g, "fetch_cn_ai_news", return_value={
"enabled": False, "categories": [], "flat": [], "stats": {}}),
mock.patch.object(g, "is_research_mode", return_value=False),
mock.patch.object(g, "is_agent_mode", return_value=False),
mock.patch.object(g, "run_editorial", return_value=None),
mock.patch.object(g, "cursor_editor_enabled", return_value=False),
mock.patch.object(g, "record_pushed_links", lambda *a, **k: None),
mock.patch.object(g, "save_json", lambda *a, **k: None),
mock.patch.object(g, "load_recent_shown_keys", return_value={
"skills_trending": set(), "skills_hot": set(),
"github_trending": set(), "github_emerging": set(),
"github_topic": set()}),
mock.patch.object(g, "OUTPUT_DIR", tmp),
mock.patch.object(g, "_localize_descriptions_in_place", lambda *a, **k: None),
# featured_pick / push_gate 有独立测试;此处冻结以免触网(LLM)与读盘。
mock.patch.object(g, "apply_featured_pick", return_value={
"title": "alpha", "url": "https://x/a",
"command": "npx skills add src/alpha", "why": "昨日 star 增速第一"}),
mock.patch.object(g, "evaluate_push_gate", return_value=mock.Mock(
should_push=False, silent=True, reasons=["golden-mock"])),
]
class GenerateGoldenTests(unittest.TestCase):
def _run(self, tmp: Path) -> tuple[str, str]:
patches = _patches(tmp)
for p in patches:
p.start()
try:
markdown, wecom_md, _md, _we = g.generate_report()
return markdown, wecom_md
finally:
for p in patches:
try:
p.stop()
except Exception:
pass
def test_deterministic_same_input_same_output(self):
import tempfile
with tempfile.TemporaryDirectory() as d1, tempfile.TemporaryDirectory() as d2:
md1, we1 = self._run(Path(d1))
md2, we2 = self._run(Path(d2))
self.assertEqual(md1, md2, "完整版 markdown 在相同输入下必须逐字节一致")
self.assertEqual(we1, we2, "企微 wecom_md 在相同输入下必须逐字节一致")
def test_output_contains_core_sections(self):
import tempfile
with tempfile.TemporaryDirectory() as d:
markdown, wecom_md = self._run(Path(d))
self.assertIn("# 早报 · 2026-07-18", markdown)
self.assertIn("主题聚类", markdown)
self.assertIsInstance(wecom_md, str)
self.assertTrue(len(wecom_md) > 0)
if __name__ == "__main__":
unittest.main()

60
tests/test_top_line.py Normal file
View File

@@ -0,0 +1,60 @@
# tests/test_top_line.py
"""T5: _top_line 看点行(theme_line 取数升级)测试。"""
from __future__ import annotations
import unittest
from unittest import mock
import daily.generate as g
FEED_HIT = {
"topTrending": [
{"title": "remotion-video", "source": "src", "description": "video tool"},
],
"topHot": [],
}
FEED_MISS = {
"topTrending": [
{"title": "zzz-nomatch", "source": "src", "description": "nothing"},
],
"topHot": [],
}
class TopLineTests(unittest.TestCase):
def test_scores_hit_returns_theme(self):
# 命中 THEME_RULES(video) -> 评分最高主题
with mock.patch.object(g, "env_bool", return_value=True):
line = g._top_line(FEED_HIT)
self.assertIn("今日主题", line)
self.assertIn("AI 多媒体", line)
def test_fallback_to_theme_names_when_no_score(self):
# 无评分命中但 theme_clusters 能聚类 -> 用主题名
feed = {
"topTrending": [
{"id": "x", "title": "runcomfy-x", "source": "s", "description": "video x"},
],
"topHot": [],
}
with mock.patch.object(g, "env_bool", return_value=True):
# 让评分落空(前 10 无命中)但 clusters(前 20)命中
line = g._top_line(feed)
self.assertIn("今日主题", line)
self.assertIn("AI 多媒体", line)
def test_switch_off_uses_legacy_detect(self):
# DAILY_WECOM_TOP_LINE=0 -> 退回 _detect_theme_line
with mock.patch.object(g, "env_bool", return_value=False):
line = g._top_line(FEED_MISS)
self.assertEqual(line, g._detect_theme_line(FEED_MISS))
def test_empty_feed_hardcoded_fallback(self):
# 完全无数据 -> 硬编码兜底, 不抛异常
with mock.patch.object(g, "env_bool", return_value=True):
line = g._top_line({"topTrending": [], "topHot": []})
self.assertIn("今日主题", line)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,14 +0,0 @@
import re
import certifi
import httpx
url = "https://skills.sh/vercel-labs/skills/find-skills"
r = httpx.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=20, verify=certifi.where())
chunks = re.findall(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', r.text, re.DOTALL)
blob = "\n".join(chunks).encode("utf-8").decode("unicode_escape", errors="ignore")
for needle in ("description", "Helps users", "SKILL.md", "summary"):
print(needle, blob.count(needle))
idx = blob.find("Helps users")
if idx >= 0:
print(blob[idx : idx + 300])

Binary file not shown.