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_TOPIC_LIMIT=10
# 企微版可选行(默认均开)
# DAILY_WECOM_TOP_LINE=1 # 顶部「今日主题」行,无评分命中时回退主题名
# DAILY_FEATURED_REASON=1 # 首推下「为什么值得点开」理由行
# GitHub Trending
GITHUB_TRENDING_SINCE=daily
# GITHUB_TRENDING_LANGUAGE=python

View File

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

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import re
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.skills_group import group_skills_by_source
from daily.text_utils import trim_brief
@@ -1096,7 +1096,8 @@ def build_wecom_report(
lines.append(f"{ICONS['pick']} **今日首推**")
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}")
return "\n".join(lines)

View File

@@ -24,6 +24,7 @@ from daily.config import (
SNAPSHOT_FILE,
board_pool_size,
env,
env_bool,
env_int,
full_desc_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.trending import fetch_github_trending, trending_data_source_note
from daily.localize import LocalizeJob, localize_descriptions, needs_chinese
from daily.narrative_axis import theme_clusters, theme_names
from daily.news.fetch import (
fetch_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]}"
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(
trending: list[dict[str, Any]],
hot: list[dict[str, Any]],
@@ -444,31 +473,11 @@ def _fetch_latest_release_title(repo: str) -> str | None:
return None
title = entry.find("a:title", ns)
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
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]:
lines: list[str] = []
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
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
trending_n = env_int("DAILY_TRENDING_LIMIT", 400)
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_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(
board="skills_trending",
items=trending,
recent_keys=skill_recent,
limit=wecom_trending,
limit=c["wecom_trending"],
pool_size=pool,
kind="skill",
)
@@ -602,7 +683,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
board="skills_hot",
items=hot,
recent_keys=skill_recent,
limit=wecom_hot,
limit=c["wecom_hot"],
pool_size=pool,
kind="skill",
)
@@ -610,7 +691,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
board="github_trending",
items=github_trending,
recent_keys=github_recent,
limit=wecom_github,
limit=c["wecom_github"],
pool_size=pool,
kind="github",
)
@@ -619,7 +700,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
board="github_emerging",
items=github_emerging,
recent_keys=github_recent,
limit=wecom_emerging,
limit=c["wecom_emerging"],
pool_size=pool,
kind="github",
)
@@ -628,7 +709,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
board="github_topic",
items=github_topic,
recent_keys=github_recent,
limit=wecom_topic,
limit=c["wecom_topic"],
pool_size=pool,
kind="github",
)
@@ -759,7 +840,179 @@ def generate_report() -> tuple[str, str, Path, Path]:
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 = [
f"# 早报 · {date_str}",
@@ -834,17 +1087,6 @@ def generate_report() -> tuple[str, str, Path, Path]:
lines.append(f"- {ex}")
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"])
for item in trending[:4]:
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`*"])
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:
wecom_md = replace_wecom_skill_sections(agent_wecom, **board_kwargs)
else:
@@ -944,7 +1105,7 @@ def generate_report() -> tuple[str, str, Path, Path]:
updated=updated,
highlights=editorial_highlights
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,
cn_ai_news=wecom_cn if not 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
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:
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_file = LOG_DIR / f"{_now_cst():%Y-%m-%d}.log"

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import logging
import random
from collections import defaultdict
from datetime import datetime, timedelta
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["narrative_axis"] = axis
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
import logging
from dataclasses import dataclass, field
from typing import Any
from daily.config import force_push, skip_push_when_silent
logger = logging.getLogger(__name__)
@dataclass
class PushGateResult:
@@ -34,6 +37,7 @@ def evaluate_push_gate(
featured_pick: dict[str, Any] | None,
) -> PushGateResult:
if force_push():
logger.info("push_gate: force_push=on, 强制推送")
return PushGateResult(should_push=True, reasons=["force_push"], silent=False)
reasons: list[str] = []
@@ -48,4 +52,10 @@ def evaluate_push_gate(
should = bool(reasons)
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)

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.